diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 000000000..05a4d8b7a --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "yaak", + "interface": { + "displayName": "Yaak" + }, + "plugins": [ + { + "name": "yaak", + "source": { + "source": "local", + "path": "./crates-cli/yaak-cli" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 000000000..bb30151f3 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-marketplace.json", + "name": "yaak", + "description": "Plugins for Yaak, the desktop API client", + "owner": { + "name": "Yaak", + "url": "https://yaak.app" + }, + "plugins": [ + { + "name": "yaak", + "source": "./crates-cli/yaak-cli", + "description": "Build, save, and send API requests with the yaak CLI, with its MCP server for agents", + "category": "development", + "tags": ["api", "http", "rest", "graphql", "grpc", "openapi", "api-client"] + } + ] +} diff --git a/.gitattributes b/.gitattributes index 67ab542ce..fdb3c5687 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,4 @@ crates/yaak-templates/pkg/* linguist-generated=true # Ensure consistent line endings for test files that check exact content crates/yaak-http/tests/test.txt text eol=lf +crates-proxy/yaak-proxy-lib/tests/fixtures/export.har text eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..0a86c6a74 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,55 @@ +version: 2 + +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + ignore: + # Build tooling majors are opted into, not queued. These never reach a + # user, so the cost of being a release behind is low and the cost of a + # surprise is a broken build. Note this also suppresses security PRs for + # these, so Dependabot alerts are the backstop. + - dependency-name: typescript + update-types: ["version-update:semver-major"] + - dependency-name: vitest + update-types: ["version-update:semver-major"] + - dependency-name: "@types/*" + update-types: ["version-update:semver-major"] + - dependency-name: "vite-plugin-*" + update-types: ["version-update:semver-major"] + # 0.8 dropped stateExtensions and updateSchema from the package root and + # exposes no subpath to reach them, which GrpcEditor needs. Unpin once + # that editor moves to the jsonSchema helper. + - dependency-name: codemirror-json-schema + groups: + npm-production: + dependency-type: production + update-types: + - minor + - patch + npm-development: + dependency-type: development + update-types: + - minor + - patch + + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + ignore: + # Held at 11.10.0 to pin what rolldown_resolver ("^11") resolves to; it + # does not build against 11.11. See crates-cli/yaak-cli/Cargo.toml. + - dependency-name: oxc_resolver + groups: + cargo: + patterns: + - "*" + update-types: + - minor + - patch diff --git a/.github/scripts/preserve-release-artifacts.mjs b/.github/scripts/preserve-release-artifacts.mjs new file mode 100644 index 000000000..e64cfe911 --- /dev/null +++ b/.github/scripts/preserve-release-artifacts.mjs @@ -0,0 +1,79 @@ +// Retain only distributable bundle files, never the target tree or signing material. +import { createHash } from "node:crypto"; +import { + copyFileSync, + createReadStream, + existsSync, + mkdirSync, + readdirSync, + writeFileSync, +} from "node:fs"; +import { join, relative } from "node:path"; + +const env = process.env; +const bundle = join("target", env.RELEASE_TARGET || "", "release", "bundle"); +const destination = join(env.RUNNER_TEMP, "yaak-release-artifacts"); +const extensions = + /\.(dmg|deb|rpm|AppImage|exe|msi|app\.tar\.gz|AppImage\.tar\.gz|nsis\.zip|msi\.zip)(\.sig)?$/; +const files = []; + +function snapshot(directory) { + if (!existsSync(directory)) return; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const source = join(directory, entry.name); + // Only descend through the bundler's immediate output folders. This excludes + // .app trees and the extracted CEF deb contents (which contain executables). + if (entry.isDirectory() && directory === bundle) { + snapshot(source); + } else if ( + entry.isFile() && + (extensions.test(entry.name) || /^yaak-cef_.*_linux_.*\.tar\.gz$/.test(entry.name)) + ) { + // Match tauri-action's default macOS updater asset naming exactly. + const arch = env.RELEASE_ARCH === "arm64" ? "aarch64" : "x64"; + const assetName = entry.name.replace(/\.app\.tar\.gz(\.sig)?$/, `_${arch}.app.tar.gz$1`); + mkdirSync(destination, { recursive: true }); + const retained = join(destination, assetName); + // The second snapshot must not overwrite the original user installer/signature. + if (!existsSync(retained)) copyFileSync(source, retained); + files.push({ source: relative(".", source), assetName }); + } + } +} + +snapshot(bundle); +if (existsSync(destination)) { + const assets = readdirSync(destination) + .filter((name) => name !== "provenance.json") + .sort(); + const checksums = []; + for (const assetName of assets) { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(join(destination, assetName))) hash.update(chunk); + checksums.push({ assetName, sha256: hash.digest("hex") }); + } + writeFileSync( + join(destination, "provenance.json"), + JSON.stringify( + { + repository: env.GITHUB_REPOSITORY, + tag: env.GITHUB_REF_NAME, + sha: env.GITHUB_SHA, + runId: env.GITHUB_RUN_ID, + runAttempt: env.GITHUB_RUN_ATTEMPT, + workflowRef: env.GITHUB_WORKFLOW_REF, + workflowSha: env.GITHUB_WORKFLOW_SHA, + tauriAction: "tauri-apps/tauri-action@v0", + platform: env.RELEASE_PLATFORM, + runtime: env.RELEASE_RUNTIME, + arch: env.RELEASE_ARCH, + target: env.RELEASE_TARGET, + args: env.RELEASE_ARGS, + assets: checksums, + }, + null, + 2, + ) + "\n", + ); +} +console.log(`Found ${files.length} distributable bundle files in ${bundle}`); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd4f90825..3a0684b13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,3 +35,29 @@ jobs: run: | cargo build -p yaak-cli node plugins/importer-openapi/tests/roundtrip.mjs + + test-windows: + name: Rust Tests (Windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: voidzero-dev/setup-vp@v1 + with: + node-version: "24" + cache: true + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + shared-key: ci + cache-on-failure: true + - name: Install Protoc for plugin-runtime + uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - run: vp install + - run: npm run bootstrap + env: + YAAK_TARGET_ARCH: x64 + - name: Run Rust Tests + run: cargo test --all --exclude yaak-cli --features yaak-app-client/wry diff --git a/.github/workflows/release-app.yml b/.github/workflows/release-app.yml index 6c5195947..a9bb6c42d 100644 --- a/.github/workflows/release-app.yml +++ b/.github/workflows/release-app.yml @@ -70,7 +70,7 @@ jobs: runtime: "wry" targets: "aarch64-pc-windows-msvc" runs-on: ${{ matrix.platform }} - timeout-minutes: 60 + timeout-minutes: 360 steps: - name: Checkout yaakapp/app uses: actions/checkout@v4 @@ -172,7 +172,8 @@ jobs: codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/protoc/yaakprotoc || true codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/node/yaaknode || true - - uses: tauri-apps/tauri-action@v0 + - name: Build and upload Tauri bundles + uses: tauri-apps/tauri-action@v0 env: YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }} @@ -194,6 +195,8 @@ jobs: AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }} AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }} with: + # Also retries builds; upload retries alone cannot clean up starter assets. + retryAttempts: 3 tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js" tagName: "v__VERSION__" releaseName: "Release __VERSION__" @@ -203,6 +206,17 @@ jobs: projectPath: ./crates-tauri/yaak-app-client args: "${{ matrix.args }}" + # Snapshot before the per-machine build overwrites the regular NSIS bundle/signature. + - name: Snapshot Tauri bundles + if: ${{ !cancelled() }} + run: node .github/scripts/preserve-release-artifacts.mjs + env: + RELEASE_TARGET: ${{ matrix.targets }} + RELEASE_PLATFORM: ${{ matrix.platform }} + RELEASE_RUNTIME: ${{ matrix.runtime }} + RELEASE_ARCH: ${{ matrix.yaak_arch }} + RELEASE_ARGS: ${{ matrix.args }} + - name: Build and upload CEF tarball from deb (Linux only) if: matrix.os == 'ubuntu' && matrix.runtime == 'cef' env: @@ -217,7 +231,13 @@ jobs: mkdir -p "$extract_dir" dpkg-deb -x "$deb" "$extract_dir" tar -C "$extract_dir" -czf "$tarball" . - gh release upload "${{ github.ref_name }}" "$tarball" --clobber + for attempt in 1 2 3 4; do + if gh release upload "$GITHUB_REF_NAME" "$tarball" --clobber; then + break + fi + if [ "$attempt" -eq 4 ]; then exit 1; fi + sleep 10 + done # Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune) - name: Build and upload machine-wide installer (Windows only) @@ -235,6 +255,7 @@ jobs: Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force Push-Location crates-tauri/yaak-app-client npx tauri bundle ${{ matrix.args }} --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}' + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } Pop-Location $setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1 $setupSig = "$($setup.FullName).sig" @@ -242,5 +263,30 @@ jobs: $destSig = "$dest.sig" Copy-Item $setup.FullName $dest Copy-Item $setupSig $destSig - gh release upload "${{ github.ref_name }}" "$dest" --clobber - gh release upload "${{ github.ref_name }}" "$destSig" --clobber + foreach ($asset in @($dest, $destSig)) { + for ($attempt = 1; $attempt -le 4; $attempt++) { + gh release upload "$env:GITHUB_REF_NAME" "$asset" --clobber + if ($LASTEXITCODE -eq 0) { break } + if ($attempt -eq 4) { exit $LASTEXITCODE } + Start-Sleep -Seconds 10 + } + } + + - name: Snapshot additional release bundles + if: ${{ !cancelled() }} + run: node .github/scripts/preserve-release-artifacts.mjs + env: + RELEASE_TARGET: ${{ matrix.targets }} + RELEASE_PLATFORM: ${{ matrix.platform }} + RELEASE_RUNTIME: ${{ matrix.runtime }} + RELEASE_ARCH: ${{ matrix.yaak_arch }} + RELEASE_ARGS: ${{ matrix.args }} + + - name: Retain release bundles for recovery + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: release-${{ github.ref_name }}-${{ matrix.platform }}-${{ matrix.runtime }}-${{ matrix.yaak_arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/yaak-release-artifacts/ + retention-days: 7 + if-no-files-found: warn diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 682f3b97c..b06ff6f53 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -3,6 +3,7 @@ "ignorePatterns": [ "**/bindings/**", "crates/yaak-templates/pkg/**", - "apps/yaak-client/routeTree.gen.ts" + "apps/yaak-client/routeTree.gen.ts", + "plugins/importer-bruno/tests/fixtures/upstream/**" ] } diff --git a/Cargo.lock b/Cargo.lock index 4adf00f72..9ff5f085a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,34 +22,23 @@ checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" [[package]] name = "aead" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "crypto-common", - "generic-array", + "crypto-common 0.2.2", + "inout 0.2.2", ] [[package]] name = "aes" -version = "0.8.4" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.16", - "once_cell", - "version_check", + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", ] [[package]] @@ -76,13 +65,28 @@ version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + [[package]] name = "alloc-stdlib" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" dependencies = [ - "alloc-no-stdlib", + "alloc-no-stdlib 2.0.4", +] + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib 3.0.0", ] [[package]] @@ -104,8 +108,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f39be698127218cca460cb624878c9aa4e2b47dba3b277963d2bf00bad263b" dependencies = [ "android_log-sys", - "env_filter", - "log 0.4.29", + "env_filter 0.1.3", + "log 0.4.34", ] [[package]] @@ -119,9 +123,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -140,9 +144,9 @@ checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -169,9 +173,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "apollo-parser" @@ -181,7 +185,7 @@ checksum = "947e21ff51879f8a40d7519dfe619268de2afba4042a8a43878276de3cb910f0" dependencies = [ "memchr", "rowan", - "thiserror 2.0.17", + "thiserror 2.0.21", ] [[package]] @@ -201,21 +205,21 @@ dependencies = [ [[package]] name = "arboard" -version = "3.5.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1df21f715862ede32a0c525ce2ca4d52626bb0007f8c18b87a384503ac33e70" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", - "log 0.4.29", - "objc2 0.6.4", + "log 0.4.34", + "objc2 0.6.5", "objc2-app-kit", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -238,24 +242,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "ashpd" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3d60bee1a1d38c2077030f4788e1b4e31058d2e79a8cfc8f2b440bd44db290" -dependencies = [ - "async-fs", - "async-net", - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.8.7", - "serde", - "serde_repr", - "url", - "zbus", -] - [[package]] name = "ashpd" version = "0.11.1" @@ -279,9 +265,9 @@ dependencies = [ [[package]] name = "assert_cmd" -version = "2.1.2" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" dependencies = [ "anstyle", "bstr", @@ -298,23 +284,12 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "event-listener 5.4.0", + "event-listener", "event-listener-strategy", "futures-core", "pin-project-lite", ] -[[package]] -name = "async-channel" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" -dependencies = [ - "concurrent-queue", - "event-listener 2.5.3", - "futures-core", -] - [[package]] name = "async-channel" version = "2.3.1" @@ -329,18 +304,14 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.23" +version = "0.4.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b37fc50485c4f3f736a4fb14199f6d5f5ba008d7f28fe710306c92780f004c07" +checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970" dependencies = [ - "brotli 8.0.1", - "flate2", - "futures-core", - "memchr", + "compression-codecs", + "compression-core", "pin-project-lite", "tokio", - "zstd", - "zstd-safe", ] [[package]] @@ -357,32 +328,6 @@ dependencies = [ "slab", ] -[[package]] -name = "async-fs" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" -dependencies = [ - "async-lock", - "blocking", - "futures-lite", -] - -[[package]] -name = "async-global-executor" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" -dependencies = [ - "async-channel 2.3.1", - "async-executor", - "async-io", - "async-lock", - "blocking", - "futures-lite", - "once_cell", -] - [[package]] name = "async-io" version = "2.4.1" @@ -396,7 +341,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.0.7", + "rustix 1.1.5", "slab", "tracing", "windows-sys 0.59.0", @@ -408,50 +353,39 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" dependencies = [ - "event-listener 5.4.0", + "event-listener", "event-listener-strategy", "pin-project-lite", ] -[[package]] -name = "async-net" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" -dependencies = [ - "async-io", - "blocking", - "futures-lite", -] - [[package]] name = "async-process" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cde3f4e40e6021d7acffc90095cbd6dc54cb593903d1de5832f435eb274b85dc" dependencies = [ - "async-channel 2.3.1", + "async-channel", "async-io", "async-lock", "async-signal", "async-task", "blocking", "cfg-if", - "event-listener 5.4.0", + "event-listener", "futures-lite", - "rustix 1.0.7", + "rustix 1.1.5", "tracing", ] [[package]] name = "async-recursion" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 3.0.3", ] [[package]] @@ -477,60 +411,12 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.0.7", + "rustix 1.1.5", "signal-hook-registry", "slab", "windows-sys 0.59.0", ] -[[package]] -name = "async-std" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" -dependencies = [ - "async-channel 1.9.0", - "async-global-executor", - "async-io", - "async-lock", - "crossbeam-utils", - "futures-channel", - "futures-core", - "futures-io", - "futures-lite", - "gloo-timers", - "kv-log-macro", - "log 0.4.29", - "memchr", - "once_cell", - "pin-project-lite", - "pin-utils", - "slab", - "wasm-bindgen-futures", -] - -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", -] - [[package]] name = "async-task" version = "4.7.1" @@ -539,13 +425,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 3.0.3", ] [[package]] @@ -634,7 +520,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tower 0.5.2", + "tower", "tower-layer", "tower-service", "tracing", @@ -685,6 +571,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64-simd" version = "0.8.0" @@ -725,18 +617,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "bitvec" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - [[package]] name = "blake3" version = "1.8.3" @@ -748,7 +628,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq 0.4.2", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -766,6 +646,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -790,7 +680,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -799,66 +689,33 @@ version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" dependencies = [ - "async-channel 2.3.1", + "async-channel", "async-task", "futures-io", "futures-lite", "piper", ] -[[package]] -name = "borsh" -version = "1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce" -dependencies = [ - "borsh-derive", - "cfg_aliases", -] - -[[package]] -name = "borsh-derive" -version = "1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd1d3c0c2f5833f22386f252fe8ed005c7f59fdcddeef025c01b4c3b9fd9ac3" -dependencies = [ - "once_cell", - "proc-macro-crate 3.3.0", - "proc-macro2", - "quote", - "syn 2.0.101", -] - -[[package]] -name = "brotli" -version = "7.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor 4.0.3", -] - [[package]] name = "brotli" version = "8.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9991eea70ea4f293524138648e41ee89b0b2b12ddef3b255effa43c8056e0e0d" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "alloc-no-stdlib 2.0.4", + "alloc-stdlib 0.2.2", "brotli-decompressor 5.0.0", ] [[package]] -name = "brotli-decompressor" -version = "4.0.3" +name = "brotli" +version = "9.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd" +checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "alloc-no-stdlib 3.0.0", + "alloc-stdlib 0.3.0", + "brotli-decompressor 6.2.0", ] [[package]] @@ -867,8 +724,18 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "alloc-no-stdlib 2.0.4", + "alloc-stdlib 0.2.2", +] + +[[package]] +name = "brotli-decompressor" +version = "6.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34c6783f42c21c952ad7112b354f0ba2f10760e9decea5b1b60523d6e270faaf" +dependencies = [ + "alloc-no-stdlib 3.0.0", + "alloc-stdlib 0.3.0", ] [[package]] @@ -900,39 +767,6 @@ dependencies = [ "allocator-api2", ] -[[package]] -name = "byte-unit" -version = "5.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cd29c3c585209b0cbc7309bfe3ed7efd8c84c21b7af29c8bfae908f8777174" -dependencies = [ - "rust_decimal", - "serde", - "utf8-width", -] - -[[package]] -name = "bytecheck" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" -dependencies = [ - "bytecheck_derive", - "ptr_meta", - "simdutf8", -] - -[[package]] -name = "bytecheck_derive" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "bytemuck" version = "1.23.0" @@ -953,22 +787,13 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] -[[package]] -name = "bzip2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" -dependencies = [ - "bzip2-sys", -] - [[package]] name = "bzip2" version = "0.6.1" @@ -978,16 +803,6 @@ dependencies = [ "libbz2-rs-sys", ] -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "cairo-rs" version = "0.18.5" @@ -1042,7 +857,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.21", ] [[package]] @@ -1070,7 +885,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -1093,7 +908,7 @@ checksum = "2445d2f1e820efc064c511faa3a07c3ee79e565fdae026ca62ed3c80d0459223" dependencies = [ "cef-dll-sys", "libloading 0.9.0", - "objc2 0.6.4", + "objc2 0.6.5", "windows-sys 0.61.2", ] @@ -1150,26 +965,26 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.9.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", + "cipher 0.5.2", + "cpufeatures 0.3.1", + "rand_core 0.10.1", ] [[package]] name = "chacha20poly1305" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" dependencies = [ "aead", "chacha20", - "cipher", + "cipher 0.5.2", "poly1305", - "zeroize", ] [[package]] @@ -1184,9 +999,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1202,16 +1017,26 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", - "zeroize", + "crypto-common 0.1.6", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", ] [[package]] name = "clap" -version = "4.5.54" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -1219,9 +1044,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -1231,21 +1056,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "clipboard-win" @@ -1265,26 +1090,32 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cobs" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror 2.0.17", + "thiserror 2.0.21", ] [[package]] name = "cocoa" -version = "0.26.1" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c" +checksum = "1e0d7076fd2bf3e3864683161b46e8b72277cdd0d304d6291faceea6e7db9a14" dependencies = [ "bitflags 2.11.0", "block", "cocoa-foundation", "core-foundation 0.10.1", - "core-graphics 0.24.0", + "core-graphics 0.25.0", "foreign-types 0.5.0", "libc", "objc", @@ -1353,6 +1184,26 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "compression-codecs" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe" +dependencies = [ + "brotli 9.1.1", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1406,6 +1257,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -1426,18 +1283,21 @@ dependencies = [ "tiny-keccak", ] -[[package]] -name = "constant_time_eq" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" - [[package]] name = "constant_time_eq" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "constant_time_eq" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb6feb175df65d0a3ccd3e73484e940d0ac664e8034f0519fd49a93db3071d9" +dependencies = [ + "rustversion", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -1455,9 +1315,9 @@ dependencies = [ [[package]] name = "cookie" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ "percent-encoding", "time", @@ -1473,8 +1333,8 @@ dependencies = [ "cookie", "document-features", "idna", - "indexmap 2.14.0", - "log 0.4.29", + "indexmap 2.14.2", + "log 0.4.34", "serde", "serde_derive", "serde_json", @@ -1593,6 +1453,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1603,10 +1469,19 @@ dependencies = [ ] [[package]] -name = "crc32fast" -version = "1.4.2" +name = "cpufeatures" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" dependencies = [ "cfg-if", ] @@ -1683,10 +1558,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + [[package]] name = "css-module-lexer" version = "0.0.15" @@ -1698,11 +1583,14 @@ dependencies = [ [[package]] name = "csscolorparser" -version = "0.7.2" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda6aace1fbef3aa217b27f4c8d7d071ef2a70a5ca51050b1f17d40299d3f16" +checksum = "75c9db70df93ea049adaa5ef1a727079ada6ccb33d4b358f262923aef4fcac8c" dependencies = [ - "phf 0.11.3", + "num-traits", + "phf 0.14.0", + "serde", + "uncased", ] [[package]] @@ -1742,7 +1630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -1762,17 +1650,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" [[package]] -name = "dark-light" -version = "2.0.0" +name = "ctutils" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e1a09f280e29a8b00bc7e81eca5ac87dca0575639c9422a5fa25a07bb884b8" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ - "ashpd 0.10.3", - "async-std", - "objc2 0.5.2", - "objc2-foundation 0.2.2", + "cmov", +] + +[[package]] +name = "dark-light" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f66b71ac6b73812b0bf51cda8dd646a268fbb38623f45b2b7907593c323f745" +dependencies = [ + "futures-channel", + "futures-core", + "objc2 0.6.5", + "objc2-foundation 0.3.2", + "wasm-bindgen", "web-sys", - "winreg 0.52.0", + "windows-sys 0.61.2", + "winreg 0.56.0", + "zbus", ] [[package]] @@ -1795,6 +1695,16 @@ dependencies = [ "darling_macro 0.23.0", ] +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -1805,7 +1715,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -1818,7 +1728,20 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.101", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", ] [[package]] @@ -1829,7 +1752,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -1840,7 +1763,18 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.101", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.3", ] [[package]] @@ -1886,9 +1820,40 @@ dependencies = [ [[package]] name = "deflate64" -version = "0.1.9" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] [[package]] name = "delegate-display" @@ -1906,11 +1871,10 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" dependencies = [ - "powerfmt", "serde_core", ] @@ -1922,7 +1886,7 @@ checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -1935,7 +1899,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -1957,7 +1921,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.101", + "syn 2.0.119", "unicode-xid", ] @@ -1967,7 +1931,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -1982,11 +1946,24 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", + "zeroize", +] + [[package]] name = "dioxus-debug-cell" version = "0.1.1" @@ -2002,6 +1979,15 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-sys" version = "0.5.0" @@ -2016,14 +2002,14 @@ dependencies = [ [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.11.0", "block2 0.6.1", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -2034,7 +2020,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -2066,7 +2052,7 @@ checksum = "788160fb30de9cdd857af31c6a2675904b16ece8fc2737b2c7127ba368c9d0f4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -2114,17 +2100,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c169adf067a787e1f1c58ed62906a557de85388bee4b54fb878b722ff606b113" dependencies = [ - "bzip2 0.6.1", + "bzip2", "clap", "fs-err", "indicatif", - "regex 1.11.1", + "regex 1.13.1", "semver", "serde", "serde_json", "sha1_smol", "tar", - "thiserror 2.0.17", + "thiserror 2.0.21", "ureq", ] @@ -2268,7 +2254,7 @@ checksum = "fc4caf64a58d7a6d65ab00639b046ff54399a39f5f2554728895ace4b297cd79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -2277,21 +2263,31 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" dependencies = [ - "log 0.4.29", - "regex 1.11.1", + "log 0.4.34", + "regex 1.13.1", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log 0.4.34", + "regex 1.13.1", ] [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", - "env_filter", + "env_filter 2.0.0", "jiff", - "log 0.4.29", + "log 0.4.34", ] [[package]] @@ -2326,12 +2322,6 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" -[[package]] -name = "event-listener" -version = "2.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" - [[package]] name = "event-listener" version = "5.4.0" @@ -2349,7 +2339,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener 5.4.0", + "event-listener", "pin-project-lite", ] @@ -2358,7 +2348,7 @@ name = "eventsource-client" version = "0.14.0" source = "git+https://github.com/yaakapp/rust-eventsource-client#60e0e3ac5038149c4778dc4979b09b152214f9a8" dependencies = [ - "log 0.4.29", + "log 0.4.34", "pin-project", ] @@ -2392,7 +2382,18 @@ checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" dependencies = [ "bit-set", "regex-automata", - "regex-syntax 0.8.5", + "regex-syntax 0.8.11", +] + +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax 0.8.11", ] [[package]] @@ -2438,7 +2439,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" dependencies = [ "colored", - "log 0.4.29", + "log 0.4.34", ] [[package]] @@ -2483,13 +2484,13 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.1" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "libz-rs-sys", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -2559,7 +2560,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -2618,12 +2619,6 @@ dependencies = [ "libc", ] -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - [[package]] name = "futf" version = "0.1.5" @@ -2636,9 +2631,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -2651,9 +2646,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -2661,15 +2656,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -2678,9 +2673,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -2697,32 +2692,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -2732,7 +2727,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -2879,7 +2873,7 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55" dependencies = [ - "rustix 1.0.7", + "rustix 1.1.5", "windows-targets 0.52.6", ] @@ -2901,23 +2895,35 @@ 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", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.2.0", + "wasip2", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.3.3" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -2955,17 +2961,15 @@ dependencies = [ [[package]] name = "git2" -version = "0.20.4" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" dependencies = [ "bitflags 2.11.0", "libc", "libgit2-sys", - "log 0.4.29", - "openssl-probe", + "log 0.4.34", "openssl-sys", - "url", ] [[package]] @@ -3002,7 +3006,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -3021,18 +3025,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" -[[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - [[package]] name = "gobject-sys" version = "0.18.0" @@ -3093,14 +3085,14 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] name = "h2" -version = "0.4.10" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -3108,7 +3100,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.14.0", + "indexmap 2.14.2", "slab", "tokio", "tokio-util", @@ -3144,9 +3136,6 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash", -] [[package]] name = "hashbrown" @@ -3219,7 +3208,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] @@ -3228,7 +3226,7 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" dependencies = [ - "log 0.4.29", + "log 0.4.34", "mac", "markup5ever 0.14.1", "match_token", @@ -3240,26 +3238,25 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ - "log 0.4.29", + "log 0.4.34", "markup5ever 0.38.0", ] [[package]] name = "http" -version = "1.3.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", - "fnv", "itoa", ] [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -3267,9 +3264,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -3303,14 +3300,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "hyper" -version = "1.6.0" +name = "hybrid-array" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2", "http", "http-body", @@ -3325,15 +3332,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -3370,23 +3376,23 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.17" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2", "system-configuration", "tokio", "tower-service", @@ -3404,7 +3410,7 @@ dependencies = [ "core-foundation-sys", "iana-time-zone-haiku", "js-sys", - "log 0.4.29", + "log 0.4.34", "wasm-bindgen", "windows-core 0.61.2", ] @@ -3599,7 +3605,7 @@ dependencies = [ "js-sys", "sealed 0.6.0", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.21", "tokio", "wasm-bindgen", "wasm-bindgen-futures", @@ -3615,7 +3621,7 @@ dependencies = [ "macroific 2.0.0", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -3631,9 +3637,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -3693,6 +3699,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "inquire" version = "0.7.5" @@ -3798,26 +3813,39 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.18" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" dependencies = [ + "defmt", + "jiff-core", "jiff-static", - "log 0.4.29", + "log 0.4.34", "portable-atomic", "portable-atomic-util", "serde_core", ] [[package]] -name = "jiff-static" -version = "0.2.18" +name = "jiff-core" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" dependencies = [ + "defmt", + "log 0.4.34", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -3829,26 +3857,75 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", - "log 0.4.29", + "jni-sys 0.3.0", + "log 0.4.34", "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log 0.4.34", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "jobserver" version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "libc", ] @@ -3926,9 +4003,9 @@ checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ "byteorder", "dbus-secret-service", - "log 0.4.29", + "log 0.4.34", "security-framework 2.11.1", - "security-framework 3.5.1", + "security-framework 3.7.0", "windows-sys 0.60.2", "zeroize", ] @@ -3961,19 +4038,10 @@ checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" dependencies = [ "cssparser 0.29.6", "html5ever 0.29.1", - "indexmap 2.14.0", + "indexmap 2.14.2", "selectors 0.24.0", ] -[[package]] -name = "kv-log-macro" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" -dependencies = [ - "log 0.4.29", -] - [[package]] name = "lazy_static" version = "0.2.11" @@ -3996,7 +4064,7 @@ dependencies = [ "gtk", "gtk-sys", "libappindicator-sys", - "log 0.4.29", + "log 0.4.34", ] [[package]] @@ -4018,9 +4086,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libdbus-sys" @@ -4033,13 +4101,12 @@ dependencies = [ [[package]] name = "libgit2-sys" -version = "0.18.3+1.9.2" +version = "0.18.8+1.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50" dependencies = [ "cc", "libc", - "libssh2-sys", "libz-sys", "openssl-sys", "pkg-config", @@ -4066,24 +4133,10 @@ dependencies = [ ] [[package]] -name = "liblzma" -version = "0.4.1" +name = "libm" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66352d7a8ac12d4877b6e6ea5a9b7650ee094257dc40889955bea5bc5b08c1d0" -dependencies = [ - "liblzma-sys", -] - -[[package]] -name = "liblzma-sys" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736" -dependencies = [ - "cc", - "libc", - "pkg-config", -] +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" @@ -4107,29 +4160,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "libssh2-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" -dependencies = [ - "cc", - "libc", - "libz-sys", - "openssl-sys", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "libz-rs-sys" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221" -dependencies = [ - "zlib-rs", -] - [[package]] name = "libz-sys" version = "1.1.22" @@ -4150,9 +4180,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -4182,16 +4212,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e19e8d5c34a3e0e2223db8e060f9e8264aeeb5c5fc64a4ee9965c062211c024b" dependencies = [ - "log 0.4.29", + "log 0.4.34", ] [[package]] name = "log" -version = "0.4.29" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lzma-rust2" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7277c9742229dc25243236d578b6471a798c554512c6f6a04365ec2e87c2a3bb" dependencies = [ - "value-bag", + "sha2 0.11.1", ] [[package]] @@ -4231,7 +4267,7 @@ dependencies = [ "proc-macro2", "quote", "sealed 0.6.0", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -4255,7 +4291,7 @@ dependencies = [ "proc-macro2", "quote", "sealed 0.6.0", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -4280,7 +4316,7 @@ dependencies = [ "macroific_core 2.0.0", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -4311,7 +4347,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" dependencies = [ - "log 0.4.29", + "log 0.4.34", "phf 0.11.3", "phf_codegen 0.11.3", "string_cache 0.8.9", @@ -4325,7 +4361,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" dependencies = [ - "log 0.4.29", + "log 0.4.34", "tendril 0.5.0", "web_atoms", ] @@ -4338,7 +4374,7 @@ checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -4353,12 +4389,6 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" -[[package]] -name = "md5" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" - [[package]] name = "md5" version = "0.8.0" @@ -4427,6 +4457,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "0.8.11" @@ -4434,21 +4474,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", - "log 0.4.29", + "log 0.4.34", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.48.0", ] [[package]] name = "mio" -version = "1.0.4" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", - "log 0.4.29", + "log 0.4.34", "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4461,39 +4501,39 @@ dependencies = [ "dpi", "gtk", "keyboard-types", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.17", + "thiserror 2.0.21", "windows-sys 0.61.2", ] [[package]] name = "nanoid" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" +checksum = "8628de41fe064cc3f0cf07f3d299ee3e73521adaff72278731d5c8cae3797873" dependencies = [ - "rand 0.8.7", + "rand 0.9.5", ] [[package]] name = "native-tls" -version = "0.2.14" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", - "log 0.4.29", + "log 0.4.34", "openssl", "openssl-probe", "openssl-sys", "schannel", - "security-framework 2.11.1", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -4505,8 +4545,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ "bitflags 2.11.0", - "jni-sys", - "log 0.4.29", + "jni-sys 0.3.0", + "log 0.4.34", "ndk-sys", "num_enum", "raw-window-handle", @@ -4525,7 +4565,7 @@ version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys", + "jni-sys 0.3.0", ] [[package]] @@ -4567,15 +4607,14 @@ dependencies = [ [[package]] name = "nix" -version = "0.30.1" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", - "memoffset 0.9.1", ] [[package]] @@ -4623,21 +4662,21 @@ checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" [[package]] name = "notify" -version = "8.0.0" +version = "8.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fee8403b3d66ac7b26aee6e40a897d85dc5ce26f44da36b8b73e987cc52e943" +checksum = "82f812702fbc75d35416d3f884e04925be7e9498f4eceea2e24ba34559284674" dependencies = [ "bitflags 2.11.0", - "filetime", - "fsevent-sys", "inotify", "kqueue", "libc", - "log 0.4.29", - "mio 1.0.4", + "log 0.4.34", + "mio 1.2.4", "notify-types", + "objc2-core-foundation", + "objc2-core-services", "walkdir", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4667,9 +4706,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -4687,6 +4726,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -4707,7 +4747,7 @@ dependencies = [ "proc-macro-crate 3.3.0", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -4746,9 +4786,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", "objc2-exception-helper", @@ -4756,77 +4796,79 @@ dependencies = [ [[package]] name = "objc2-app-kit" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.0", "block2 0.6.1", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-cloud-kit", "objc2-core-data", "objc2-core-foundation", "objc2-core-graphics", "objc2-core-image", - "objc2-foundation 0.3.1", - "objc2-quartz-core 0.3.1", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] name = "objc2-cloud-kit" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17614fdcd9b411e6ff1117dfb1d0150f908ba83a7df81b1f118005fe0a8ea15d" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ "bitflags 2.11.0", - "objc2 0.6.4", - "objc2-foundation 0.3.1", + "objc2 0.6.5", + "objc2-foundation 0.3.2", ] [[package]] name = "objc2-core-data" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291fbbf7d29287518e8686417cf7239c74700fd4b607623140a7d4a3c834329d" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ "bitflags 2.11.0", - "objc2 0.6.4", - "objc2-foundation 0.3.1", + "objc2 0.6.5", + "objc2-foundation 0.3.2", ] [[package]] name = "objc2-core-foundation" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.0", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] name = "objc2-core-graphics" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.11.0", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] [[package]] name = "objc2-core-image" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79b3dc0cc4386b6ccf21c157591b34a7f44c8e75b064f85502901ab2188c007e" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" dependencies = [ - "objc2 0.6.4", - "objc2-foundation 0.3.1", + "objc2 0.6.5", + "objc2-foundation 0.3.2", ] [[package]] @@ -4835,8 +4877,43 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac0f75792558aa9d618443bbb5db7426a7a0b6fddf96903f86ef9ad02e135740" dependencies = [ - "objc2 0.6.4", - "objc2-foundation 0.3.1", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", ] [[package]] @@ -4868,25 +4945,25 @@ dependencies = [ [[package]] name = "objc2-foundation" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.0", "block2 0.6.1", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] [[package]] name = "objc2-io-surface" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.11.0", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -4896,7 +4973,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9052cb1bb50a4c161d934befcf879526fb87ae9a68858f241e693ca46225cf5a" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -4919,9 +4996,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26bb88504b5a050dbba515d2414607bf5e57dd56b107bc5f0351197a3e7bdc5d" dependencies = [ "bitflags 2.11.0", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", ] [[package]] @@ -4939,27 +5016,39 @@ dependencies = [ [[package]] name = "objc2-quartz-core" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.11.0", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", ] [[package]] name = "objc2-security" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1f8e0ef3ab66b08c42644dcb34dba6ec0a574bbd8adbb8bdbdc7a2779731a44" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ "bitflags 2.11.0", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] +[[package]] +name = "objc2-service-management" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b213642d6959cc6023ceb1217aa595eaaf09b8094ce95127c103cab611fe65e8" +dependencies = [ + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-security", +] + [[package]] name = "objc2-ui-kit" version = "0.3.1" @@ -4968,15 +5057,15 @@ checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed" dependencies = [ "bitflags 2.11.0", "block2 0.6.1", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-cloud-kit", "objc2-core-data", "objc2-core-foundation", "objc2-core-graphics", "objc2-core-image", "objc2-core-location", - "objc2-foundation 0.3.1", - "objc2-quartz-core 0.3.1", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "objc2-user-notifications", ] @@ -4986,8 +5075,8 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a3f5ec77a81d9e0c5a0b32159b0cb143d7086165e79708351e02bf37dfc65cd" dependencies = [ - "objc2 0.6.4", - "objc2-foundation 0.3.1", + "objc2 0.6.5", + "objc2-foundation 0.3.2", ] [[package]] @@ -4998,10 +5087,10 @@ checksum = "91672909de8b1ce1c2252e95bbee8c1649c9ad9d14b9248b3d7b4c47903c47ad" dependencies = [ "bitflags 2.11.0", "block2 0.6.1", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "objc2-javascript-core", "objc2-security", ] @@ -5018,12 +5107,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "open" version = "5.3.2" @@ -5058,14 +5141,14 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" @@ -5130,7 +5213,7 @@ version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fc863e2ca13dc2d5c34fb22ea4a588248ac14db929616ba65c45f21744b1e9" dependencies = [ - "log 0.4.29", + "log 0.4.34", "serde", "windows-sys 0.52.0", ] @@ -5151,12 +5234,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" dependencies = [ - "objc2 0.6.4", - "objc2-foundation 0.3.1", + "objc2 0.6.5", + "objc2-foundation 0.3.2", "objc2-osa-kit", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.21", ] [[package]] @@ -5206,7 +5289,7 @@ dependencies = [ "rustc-hash 2.1.1", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.21", ] [[package]] @@ -5219,7 +5302,7 @@ dependencies = [ "owo-colors", "oxc-miette-derive", "textwrap", - "thiserror 2.0.17", + "thiserror 2.0.21", "unicode-segmentation", "unicode-width 0.2.2", ] @@ -5232,7 +5315,7 @@ checksum = "d4faecb54d0971f948fbc1918df69b26007e6f279a204793669542e1e8b75eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -5276,7 +5359,7 @@ dependencies = [ "phf 0.13.1", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -5501,7 +5584,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ec96245d28afd809ea0d830d0cdedb372907d5f6d9c8fac2683888f14dfd62c" dependencies = [ "cfg-if", - "indexmap 2.14.0", + "indexmap 2.14.2", "json-strip-comments", "libc", "once_cell", @@ -5513,7 +5596,7 @@ dependencies = [ "serde_json", "simd-json", "simdutf8", - "thiserror 2.0.17", + "thiserror 2.0.21", "tracing", "url", "windows 0.62.0", @@ -5597,7 +5680,7 @@ checksum = "7d55a4cd5291f307ae9138e30de09b6b3004b07001df7b70b9d9d72b87593d07" dependencies = [ "base64 0.22.1", "compact_str", - "indexmap 2.14.0", + "indexmap 2.14.2", "itoa", "memchr", "oxc_allocator", @@ -5616,7 +5699,7 @@ dependencies = [ "rustc-hash 2.1.1", "serde", "serde_json", - "sha1", + "sha1 0.10.7", ] [[package]] @@ -5666,14 +5749,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4873306de53fe82e7e484df31e1e947d61514b6ea2ed6cd7b45d63006fd9224" dependencies = [ "cbc", - "cipher", + "cipher 0.4.4", "des", "getrandom 0.2.16", - "hmac", + "hmac 0.12.1", "lazy_static 1.5.0", "rc2", - "sha1", - "yasna", + "sha1 0.10.7", + "yasna 0.5.2", ] [[package]] @@ -5740,6 +5823,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "path-slash" version = "0.2.1" @@ -5754,12 +5843,12 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "pbkdf2" -version = "0.12.2" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ - "digest", - "hmac", + "digest 0.11.3", + "hmac 0.13.0", ] [[package]] @@ -5778,6 +5867,12 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "peresil" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f658886ed52e196e850cfbbfddab9eaa7f6d90dd0929e264c31e5cec07e09e57" + [[package]] name = "pest" version = "0.3.3" @@ -5791,7 +5886,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset 0.4.2", - "indexmap 2.14.0", + "indexmap 2.14.2", ] [[package]] @@ -5802,7 +5897,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset 0.5.7", "hashbrown 0.15.3", - "indexmap 2.14.0", + "indexmap 2.14.2", "serde", ] @@ -5832,7 +5927,6 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros 0.11.3", "phf_shared 0.11.3", ] @@ -5847,6 +5941,17 @@ dependencies = [ "serde", ] +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", + "serde", +] + [[package]] name = "phf_codegen" version = "0.8.0" @@ -5894,7 +5999,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" dependencies = [ "phf_shared 0.10.0", - "rand 0.8.7", + "rand 0.8.8", ] [[package]] @@ -5904,7 +6009,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.7", + "rand 0.8.8", ] [[package]] @@ -5917,6 +6022,16 @@ dependencies = [ "phf_shared 0.13.1", ] +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", +] + [[package]] name = "phf_macros" version = "0.10.0" @@ -5931,19 +6046,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.101", -] - [[package]] name = "phf_macros" version = "0.13.1" @@ -5954,7 +6056,21 @@ dependencies = [ "phf_shared 0.13.1", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "uncased", ] [[package]] @@ -5993,6 +6109,16 @@ dependencies = [ "siphasher 1.0.1", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher 1.0.1", + "uncased", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -6010,7 +6136,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -6019,12 +6145,6 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "piper" version = "0.2.4" @@ -6044,13 +6164,13 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "plist" -version = "1.7.1" +version = "1.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac26e981c03a6e53e0aee43c113e3202f5581d5360dae7bd2c70e800dd0451d" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" dependencies = [ - "base64 0.22.1", - "indexmap 2.14.0", - "quick-xml 0.32.0", + "base64 0.23.1", + "indexmap 2.14.2", + "quick-xml 0.42.0", "serde", "time", ] @@ -6065,7 +6185,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.8", ] [[package]] @@ -6078,7 +6198,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.8", ] [[package]] @@ -6089,7 +6209,7 @@ checksum = "a6e38320d5a8e386647f622067588bdb338c9e6e43eb32cf6f8991dd0e8f0046" dependencies = [ "byteorder", "concurrent_lru", - "fancy-regex", + "fancy-regex 0.17.0", "flate2", "nodejs-built-in-modules", "pathdiff", @@ -6097,7 +6217,7 @@ dependencies = [ "rustc-hash 2.1.1", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.21", ] [[package]] @@ -6110,7 +6230,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.0.7", + "rustix 1.1.5", "tracing", "windows-sys 0.59.0", ] @@ -6123,12 +6243,11 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] name = "poly1305" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" dependencies = [ - "cpufeatures", - "opaque-debug", + "cpufeatures 0.3.1", "universal-hash", ] @@ -6174,6 +6293,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppmd-rust" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd4acc8d264ea385db64ccfdcdfd5909ee9eda0975666723d59f3bfec80b3611" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -6200,7 +6325,7 @@ dependencies = [ "float-cmp", "normalize-line-endings", "predicates-core", - "regex 1.11.1", + "regex 1.13.1", ] [[package]] @@ -6291,18 +6416,18 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "prost" -version = "0.13.5" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", @@ -6310,25 +6435,24 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.13.5" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] name = "prost-reflect" -version = "0.14.7" +version = "0.16.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5edd582b62f5cde844716e66d92565d7faf7ab1445c8cebce6e00fba83ddb2" +checksum = "01b80ea363c31af2de2b92e3c07ed1156628f7838c4afb4df75ee78a37fedbd1" dependencies = [ "base64 0.22.1", - "once_cell", "prost", "prost-reflect-derive", "prost-types", @@ -6338,44 +6462,24 @@ dependencies = [ [[package]] name = "prost-reflect-derive" -version = "0.14.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4fce6b22f15cc8d8d400a2b98ad29202b33bd56c7d9ddd815bc803a807ecb65" +checksum = "30320eb03b43b7dfcaf9b361f808a4f1adad1e718ad219df1d7e4283e34e73f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] name = "prost-types" -version = "0.13.5" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ "prost", ] -[[package]] -name = "ptr_meta" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "quick-error" version = "1.2.3" @@ -6384,27 +6488,27 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quick-xml" -version = "0.32.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] [[package]] name = "quick-xml" -version = "0.37.5" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" dependencies = [ "memchr", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -6415,13 +6519,19 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "r2d2" version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" dependencies = [ - "log 0.4.29", + "log 0.4.34", "parking_lot", "scheduled-thread-pool", ] @@ -6437,12 +6547,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - [[package]] name = "radix_trie" version = "0.3.0" @@ -6469,9 +6573,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -6488,6 +6592,17 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -6542,9 +6657,15 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_hc" version = "0.2.0" @@ -6595,7 +6716,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62c64daa8e9438b84aaae55010a93f396f8e60e3911590fcba770d04643fc1dd" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -6608,7 +6729,7 @@ dependencies = [ "ring", "rustls-pki-types", "time", - "yasna", + "yasna 0.5.2", ] [[package]] @@ -6628,7 +6749,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.17", + "thiserror 2.0.21", ] [[package]] @@ -6648,7 +6769,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -6666,25 +6787,25 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick 1.1.3", "memchr", "regex-automata", - "regex-syntax 0.8.5", + "regex-syntax 0.8.11", ] [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick 1.1.3", "memchr", - "regex-syntax 0.8.5", + "regex-syntax 0.8.11", ] [[package]] @@ -6698,9 +6819,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "regress" @@ -6712,23 +6833,13 @@ dependencies = [ "memchr", ] -[[package]] -name = "rend" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" -dependencies = [ - "bytecheck", -] - [[package]] name = "reqwest" -version = "0.12.20" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "async-compression", - "base64 0.22.1", + "base64 0.23.1", "bytes", "encoding_rs", "futures-core", @@ -6742,7 +6853,7 @@ dependencies = [ "hyper-tls", "hyper-util", "js-sys", - "log 0.4.29", + "log 0.4.34", "mime", "mime_guess", "native-tls", @@ -6750,6 +6861,7 @@ dependencies = [ "pin-project-lite", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "serde_urlencoded", @@ -6758,52 +6870,13 @@ dependencies = [ "tokio-native-tls", "tokio-rustls", "tokio-util", - "tower 0.5.2", - "tower-http", + "tower", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.4.2", - "web-sys", -] - -[[package]] -name = "reqwest" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log 0.4.29", - "percent-encoding", - "pin-project-lite", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower 0.5.2", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams 0.5.0", + "wasm-streams", "web-sys", ] @@ -6813,18 +6886,18 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" dependencies = [ - "ashpd 0.11.1", + "ashpd", "block2 0.6.1", "dispatch2", "glib-sys", "gobject-sys", "gtk-sys", "js-sys", - "log 0.4.29", - "objc2 0.6.4", + "log 0.4.34", + "objc2 0.6.5", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "pollster", "raw-window-handle", "urlencoding", @@ -6848,35 +6921,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rkyv" -version = "0.7.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" -dependencies = [ - "bitvec", - "bytecheck", - "bytes", - "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", - "seahash", - "tinyvec", - "uuid", -] - -[[package]] -name = "rkyv_derive" -version = "0.7.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503d1d27590a2b0a3a4ca4c94755aa2875657196ecbf401a42eff41d7de532c0" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "rlimit" version = "0.11.0" @@ -6886,6 +6930,52 @@ dependencies = [ "libc", ] +[[package]] +name = "rmcp" +version = "3.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8be15815186873ea063bf005011d16c356804973489e40c39116ae3a2878d1f" +dependencies = [ + "async-trait", + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "indexmap 2.14.2", + "libc", + "pastey", + "pin-project-lite", + "rand 0.10.3", + "rmcp-macros", + "schemars 1.2.1", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.21", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b0748e054a5f5598b3213cf72099ae6b2745ba4d996c59ccf455150a7dbb839" +dependencies = [ + "darling 0.24.1", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.3", +] + [[package]] name = "rolldown" version = "0.1.0" @@ -6901,7 +6991,7 @@ dependencies = [ "derive_more 2.1.1", "dunce", "futures", - "indexmap 2.14.0", + "indexmap 2.14.2", "itertools", "itoa", "json-escape-simd", @@ -6972,8 +7062,8 @@ dependencies = [ "inotify", "kqueue", "libc", - "log 0.4.29", - "mio 1.0.4", + "log 0.4.34", + "mio 1.2.4", "rolldown-notify-types", "walkdir", "windows-sys 0.61.2", @@ -6985,7 +7075,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69128ae9ebf6d65638157e79dc9430e9341179aed9d85dde315ce6a702dba7e1" dependencies = [ - "log 0.4.29", + "log 0.4.34", "rolldown-file-id", "rolldown-notify", "rolldown-notify-types", @@ -7230,7 +7320,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfe1535df7c67d246e3394196a6b89050ef63cb0a6c0d1c70bb32d747be94d6" dependencies = [ - "regex 1.11.1", + "regex 1.13.1", ] [[package]] @@ -7260,7 +7350,7 @@ dependencies = [ "fast-glob", "form_urlencoded", "futures", - "indexmap 2.14.0", + "indexmap 2.14.2", "infer", "itoa", "memchr", @@ -7270,7 +7360,7 @@ dependencies = [ "oxc_index", "phf 0.13.1", "rayon", - "regex 1.11.1", + "regex 1.13.1", "regress", "rolldown_std_utils", "rustc-hash 2.1.1", @@ -7322,7 +7412,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.17", + "thiserror 2.0.21", ] [[package]] @@ -7352,22 +7442,6 @@ dependencies = [ "trim-in-place", ] -[[package]] -name = "rust_decimal" -version = "1.37.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faa7de2ba56ac291bd90c6b9bece784a52ae1411f9506544b3eae36dd2356d50" -dependencies = [ - "arrayvec", - "borsh", - "bytes", - "num-traits", - "rand 0.8.7", - "rkyv", - "serde", - "serde_json", -] - [[package]] name = "rustc-hash" version = "1.1.0" @@ -7404,25 +7478,25 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.7" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys 0.9.4", - "windows-sys 0.59.0", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.34" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", - "log 0.4.29", + "log 0.4.34", "once_cell", "ring", "rustls-pki-types", @@ -7433,14 +7507,14 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.1" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.5.1", + "security-framework 3.7.0", ] [[package]] @@ -7463,20 +7537,20 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni", - "log 0.4.29", + "jni 0.22.4", + "log 0.4.34", "once_cell", "rustls", "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework 3.5.1", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -7484,15 +7558,15 @@ dependencies = [ [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -7523,11 +7597,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7573,6 +7647,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", "schemars_derive 1.2.1", @@ -7591,7 +7666,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -7603,7 +7678,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -7639,8 +7714,8 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.101", - "thiserror 2.0.17", + "syn 2.0.119", + "thiserror 2.0.21", ] [[package]] @@ -7653,12 +7728,6 @@ dependencies = [ "sea-query", ] -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - [[package]] name = "sealed" version = "0.6.0" @@ -7667,7 +7736,7 @@ checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -7696,9 +7765,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.5.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", "core-foundation 0.10.1", @@ -7709,9 +7778,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -7737,7 +7806,7 @@ dependencies = [ "cssparser 0.29.6", "derive_more 0.99.20", "fxhash", - "log 0.4.29", + "log 0.4.34", "phf 0.8.0", "phf_codegen 0.8.0", "precomputed-hash", @@ -7754,7 +7823,7 @@ dependencies = [ "bitflags 2.11.0", "cssparser 0.36.0", "derive_more 2.1.1", - "log 0.4.29", + "log 0.4.34", "new_debug_unreachable", "phf 0.13.1", "phf_codegen 0.13.1", @@ -7787,9 +7856,9 @@ checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -7829,22 +7898,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 3.0.3", ] [[package]] @@ -7855,21 +7924,22 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ - "indexmap 2.14.0", + "foldhash 0.2.0", + "indexmap 2.14.2", "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -7891,7 +7961,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -7935,7 +8005,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.0", + "indexmap 2.14.2", "schemars 0.9.0", "schemars 1.2.1", "serde_core", @@ -7953,7 +8023,7 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -7962,7 +8032,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "itoa", "ryu", "serde", @@ -7988,7 +8058,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -8033,13 +8103,24 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -8055,8 +8136,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -8116,9 +8208,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd-json" @@ -8132,6 +8224,16 @@ dependencies = [ "value-trait", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -8173,22 +8275,12 @@ checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" [[package]] name = "socket2" -version = "0.5.10" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" -dependencies = [ - "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8213,7 +8305,7 @@ dependencies = [ "core-graphics 0.24.0", "foreign-types 0.5.0", "js-sys", - "log 0.4.29", + "log 0.4.34", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-quartz-core 0.2.2", @@ -8271,13 +8363,26 @@ dependencies = [ "indexed_db_futures", "js-sys", "rsqlite-vfs", - "thiserror 2.0.17", + "thiserror 2.0.21", "tokio", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", ] +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -8390,6 +8495,27 @@ dependencies = [ "serde_json", ] +[[package]] +name = "sxd-document" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94d82f37be9faf1b10a82c4bd492b74f698e40082f0f40de38ab275f31d42078" +dependencies = [ + "peresil", + "typed-arena", +] + +[[package]] +name = "sxd-xpath" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36e39da5d30887b5690e29de4c5ebb8ddff64ebd9933f98a01daaa4fd11b36ea" +dependencies = [ + "peresil", + "quick-error", + "sxd-document", +] + [[package]] name = "syn" version = "1.0.109" @@ -8403,9 +8529,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.101" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -8440,7 +8566,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -8466,9 +8592,9 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags 2.11.0", "core-foundation 0.9.4", @@ -8516,14 +8642,14 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", - "log 0.4.29", + "log 0.4.34", "ndk", "ndk-sys", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "objc2-ui-kit", "once_cell", "parking_lot", @@ -8546,15 +8672,9 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "tar" version = "0.4.46" @@ -8580,29 +8700,29 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", - "getrandom 0.3.3", + "getrandom 0.3.4", "glob", "gtk", "heck 0.5.0", "http", "http-range", - "jni", + "jni 0.21.1", "libc", - "log 0.4.29", + "log 0.4.34", "mime", "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "objc2-ui-kit", "objc2-web-kit", "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.13.3", + "reqwest", "serde", "serde_json", "serde_repr", @@ -8613,8 +8733,8 @@ dependencies = [ "tauri-runtime", "tauri-runtime-cef", "tauri-runtime-wry", - "tauri-utils 2.9.1 (git+https://github.com/tauri-apps/tauri?rev=d9bc695c18d9a25baec21d8a5f36d72e3a14ee53)", - "thiserror 2.0.17", + "tauri-utils 2.9.1", + "thiserror 2.0.21", "tokio", "tray-icon", "url", @@ -8631,7 +8751,7 @@ source = "git+https://github.com/tauri-apps/tauri?rev=d9bc695c18d9a25baec21d8a5f dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -8639,7 +8759,7 @@ dependencies = [ "semver", "serde", "serde_json", - "tauri-utils 2.9.1 (git+https://github.com/tauri-apps/tauri?rev=d9bc695c18d9a25baec21d8a5f36d72e3a14ee53)", + "tauri-utils 2.9.1", "tauri-winres", "walkdir", ] @@ -8660,10 +8780,10 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", - "syn 2.0.101", - "tauri-utils 2.9.1 (git+https://github.com/tauri-apps/tauri?rev=d9bc695c18d9a25baec21d8a5f36d72e3a14ee53)", - "thiserror 2.0.17", + "sha2 0.10.9", + "syn 2.0.119", + "tauri-utils 2.9.1", + "thiserror 2.0.21", "time", "url", "uuid", @@ -8678,16 +8798,16 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", "tauri-codegen", - "tauri-utils 2.9.1 (git+https://github.com/tauri-apps/tauri?rev=d9bc695c18d9a25baec21d8a5f36d72e3a14ee53)", + "tauri-utils 2.9.1", ] [[package]] name = "tauri-plugin" -version = "2.6.1" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" dependencies = [ "anyhow", "glob", @@ -8695,30 +8815,30 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", - "tauri-utils 2.9.1 (registry+https://github.com/rust-lang/crates.io-index)", + "tauri-utils 2.9.3", "walkdir", ] [[package]] name = "tauri-plugin-clipboard-manager" -version = "2.3.2" +version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf" +checksum = "4136fb69d967753d000423d7e5f863f89bf949efbdfbecb43a580426a01a0194" dependencies = [ "arboard", - "log 0.4.29", + "log 0.4.34", "serde", "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.21", ] [[package]] name = "tauri-plugin-deep-link" -version = "2.4.9" +version = "2.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" +checksum = "92d489b8ecceae1cd09f6e1f7606f2095ac721cc8d54cf2f0e6bb377cc52cff6" dependencies = [ "dunce", "plist", @@ -8727,8 +8847,8 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "tauri-utils 2.9.1 (registry+https://github.com/rust-lang/crates.io-index)", - "thiserror 2.0.17", + "tauri-utils 2.9.3", + "thiserror 2.0.21", "tracing", "url", "windows-registry", @@ -8737,11 +8857,11 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.7.1" +version = "2.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +checksum = "61854a36651aa48381e5e209f69a01273b77f3f9f91f0c430b1b98d33bd47229" dependencies = [ - "log 0.4.29", + "log 0.4.34", "raw-window-handle", "rfd", "serde", @@ -8749,21 +8869,21 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.17", + "thiserror 2.0.21", "url", ] [[package]] name = "tauri-plugin-fs" -version = "2.5.1" +version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51" dependencies = [ "anyhow", "dunce", "glob", - "log 0.4.29", - "objc2-foundation 0.3.1", + "log 0.4.34", + "objc2-foundation 0.3.2", "percent-encoding", "schemars 0.8.22", "serde", @@ -8771,51 +8891,50 @@ dependencies = [ "serde_repr", "tauri", "tauri-plugin", - "tauri-utils 2.9.1 (registry+https://github.com/rust-lang/crates.io-index)", - "thiserror 2.0.17", + "tauri-utils 2.9.3", + "thiserror 2.0.21", "toml 1.1.2+spec-1.1.0", "url", ] [[package]] name = "tauri-plugin-log" -version = "2.8.0" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" +checksum = "86f2e3c40b77fd1eb7f8e1b6900fb036aee2da1ea2d3783c91255a82c636649d" dependencies = [ "android_logger", - "byte-unit", "fern", - "log 0.4.29", - "objc2 0.6.4", - "objc2-foundation 0.3.1", + "log 0.4.34", + "objc2 0.6.5", + "objc2-foundation 0.3.2", "serde", "serde_json", "serde_repr", "swift-rs", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.21", "time", ] [[package]] name = "tauri-plugin-opener" -version = "2.5.4" +version = "2.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91" dependencies = [ "dunce", "glob", "objc2-app-kit", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "open", "schemars 0.8.22", "serde", "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.21", "url", "windows 0.61.1", "zbus", @@ -8828,7 +8947,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997" dependencies = [ "gethostname 1.0.2", - "log 0.4.29", + "log 0.4.34", "os_info", "serde", "serde_json", @@ -8836,41 +8955,42 @@ dependencies = [ "sys-locale", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.21", ] [[package]] name = "tauri-plugin-shell" -version = "2.3.5" +version = "2.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +checksum = "8548af174c5516e4f71f142acea4d02e00316296ea9aafa58798851481003e3c" dependencies = [ "encoding_rs", - "log 0.4.29", + "log 0.4.34", "open", "os_pipe", - "regex 1.11.1", + "regex 1.13.1", "schemars 0.8.22", "serde", "serde_json", "shared_child", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.21", "tokio", ] [[package]] name = "tauri-plugin-single-instance" -version = "2.4.2" +version = "2.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +checksum = "db817fe9295e19b7d8357e900af31edb93703dd9fb6de524b007b47b6afc63b0" dependencies = [ "serde", "serde_json", "tauri", "tauri-plugin-deep-link", - "thiserror 2.0.17", + "thiserror 2.0.21", + "tokio", "tracing", "windows-sys 0.60.2", "zbus", @@ -8878,21 +8998,21 @@ dependencies = [ [[package]] name = "tauri-plugin-updater" -version = "2.10.1" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b" dependencies = [ "base64 0.22.1", - "dirs", + "dirs 6.0.0", "flate2", "futures-util", "http", "infer", - "log 0.4.29", + "log 0.4.34", "minisign-verify", "osakit", "percent-encoding", - "reqwest 0.13.3", + "reqwest", "rustls", "semver", "serde", @@ -8901,12 +9021,12 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.21", "time", "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -8918,14 +9038,14 @@ dependencies = [ "dpi", "gtk", "http", - "jni", - "objc2 0.6.4", + "jni 0.21.1", + "objc2 0.6.5", "objc2-ui-kit", "raw-window-handle", "serde", "serde_json", - "tauri-utils 2.9.1 (git+https://github.com/tauri-apps/tauri?rev=d9bc695c18d9a25baec21d8a5f36d72e3a14ee53)", - "thiserror 2.0.17", + "tauri-utils 2.9.1", + "thiserror 2.0.21", "url", "windows 0.61.1", ] @@ -8939,21 +9059,21 @@ dependencies = [ "cef", "cef-dll-sys", "dioxus-debug-cell", - "dirs", + "dirs 6.0.0", "gtk", "html5ever 0.29.1", "http", "kuchikiki", - "log 0.4.29", - "objc2 0.6.4", + "log 0.4.34", + "objc2 0.6.5", "objc2-app-kit", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "raw-window-handle", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tauri-runtime", - "tauri-utils 2.9.1 (git+https://github.com/tauri-apps/tauri?rev=d9bc695c18d9a25baec21d8a5f36d72e3a14ee53)", + "tauri-utils 2.9.1", "url", "windows 0.61.1", "x11-dl", @@ -8966,9 +9086,9 @@ source = "git+https://github.com/tauri-apps/tauri?rev=d9bc695c18d9a25baec21d8a5f dependencies = [ "gtk", "http", - "jni", - "log 0.4.29", - "objc2 0.6.4", + "jni 0.21.1", + "log 0.4.34", + "objc2 0.6.5", "objc2-app-kit", "objc2-web-kit", "once_cell", @@ -8977,7 +9097,7 @@ dependencies = [ "softbuffer", "tao", "tauri-runtime", - "tauri-utils 2.9.1 (git+https://github.com/tauri-apps/tauri?rev=d9bc695c18d9a25baec21d8a5f36d72e3a14ee53)", + "tauri-utils 2.9.1", "url", "webkit2gtk", "webview2-com", @@ -8985,42 +9105,6 @@ dependencies = [ "wry", ] -[[package]] -name = "tauri-utils" -version = "2.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec" -dependencies = [ - "anyhow", - "cargo_metadata", - "ctor", - "dom_query", - "dunce", - "glob", - "http", - "infer", - "json-patch", - "log 0.4.29", - "memchr", - "phf 0.13.1", - "plist", - "proc-macro2", - "quote", - "regex 1.11.1", - "schemars 0.8.22", - "semver", - "serde", - "serde-untagged", - "serde_json", - "serde_with", - "swift-rs", - "thiserror 2.0.17", - "toml 1.1.2+spec-1.1.0", - "url", - "urlpattern", - "uuid", -] - [[package]] name = "tauri-utils" version = "2.9.1" @@ -9038,13 +9122,13 @@ dependencies = [ "infer", "json-patch", "kuchikiki", - "log 0.4.29", + "log 0.4.34", "memchr", "phf 0.13.1", "plist", "proc-macro2", "quote", - "regex 1.11.1", + "regex 1.13.1", "schemars 1.2.1", "semver", "serde", @@ -9052,7 +9136,7 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.17", + "thiserror 2.0.21", "toml 1.1.2+spec-1.1.0", "url", "urlpattern", @@ -9060,6 +9144,42 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log 0.4.34", + "memchr", + "phf 0.13.1", + "plist", + "proc-macro2", + "quote", + "regex 1.13.1", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.21", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", +] + [[package]] name = "tauri-winres" version = "0.3.1" @@ -9067,21 +9187,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8d321dbc6f998d825ab3f0d62673e810c861aac2d0de2cc2c395328f1d113b4" dependencies = [ "embed-resource", - "indexmap 2.14.0", + "indexmap 2.14.2", "toml 0.8.23", ] [[package]] name = "tempfile" -version = "3.20.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom 0.4.3", "once_cell", - "rustix 1.0.7", - "windows-sys 0.59.0", + "rustix 1.1.5", + "windows-sys 0.61.2", ] [[package]] @@ -9148,11 +9268,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.21", ] [[package]] @@ -9163,18 +9283,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 3.0.3", ] [[package]] @@ -9208,12 +9328,12 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", + "js-sys", "libc", "num-conv", "num_threads", @@ -9225,15 +9345,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -9281,16 +9401,16 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", - "mio 1.0.4", + "mio 1.2.4", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.1", + "socket2", "tokio-macros", "tracing", "windows-sys 0.61.2", @@ -9298,13 +9418,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 3.0.3", ] [[package]] @@ -9319,9 +9439,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -9329,9 +9449,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -9340,12 +9460,12 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.26.2" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" dependencies = [ "futures-util", - "log 0.4.29", + "log 0.4.34", "rustls", "rustls-native-certs", "rustls-pki-types", @@ -9356,13 +9476,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.15" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -9385,7 +9506,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde_core", "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", @@ -9418,7 +9539,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "toml_datetime 0.6.11", "winnow 0.5.40", ] @@ -9429,7 +9550,7 @@ version = "0.20.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "toml_datetime 0.6.11", "winnow 0.5.40", ] @@ -9440,7 +9561,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -9471,13 +9592,11 @@ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tonic" -version = "0.12.3" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +checksum = "e2cdfc673aabd30fe8fee92e65837b644c37c5d47f0f97ae6d5e0f1237d15e1b" dependencies = [ - "async-stream", "async-trait", - "axum", "base64 0.22.1", "bytes", "h2", @@ -9489,42 +9608,54 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "prost", - "socket2 0.5.10", + "socket2", + "sync_wrapper", "tokio", "tokio-stream", - "tower 0.4.13", + "tokio-util", + "tower", "tower-layer", "tower-service", "tracing", ] [[package]] -name = "tonic-reflection" -version = "0.12.3" +name = "tonic-prost" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "878d81f52e7fcfd80026b7fdb6a9b578b3c3653ba987f87f0dce4b64043cba27" +checksum = "eba464054e2226f182a34489b8b079bf4c6e3e7ab5fd7fc30e024f24aa92a484" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-reflection" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb4700e220803efd45baf3babaa1958c79adc9e0fe6fbd4422f9188d1e57d26" dependencies = [ "prost", "prost-types", "tokio", "tokio-stream", "tonic", + "tonic-prost", ] [[package]] name = "tower" -version = "0.4.13" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 1.9.3", - "pin-project", + "indexmap 2.14.2", "pin-project-lite", - "rand 0.8.7", "slab", + "sync_wrapper", "tokio", "tokio-util", "tower-layer", @@ -9533,26 +9664,33 @@ dependencies = [ ] [[package]] -name = "tower" -version = "0.5.2" +name = "tower-http" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ + "async-compression", + "bitflags 2.11.0", + "bytes", "futures-core", "futures-util", + "http", + "http-body", + "http-body-util", "pin-project-lite", - "sync_wrapper", "tokio", + "tokio-util", + "tower", "tower-layer", "tower-service", - "tracing", + "url", ] [[package]] name = "tower-http" -version = "0.6.10" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" dependencies = [ "async-compression", "bitflags 2.11.0", @@ -9570,10 +9708,8 @@ dependencies = [ "pin-project-lite", "tokio", "tokio-util", - "tower 0.5.2", "tower-layer", "tower-service", - "url", ] [[package]] @@ -9594,7 +9730,7 @@ version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ - "log 0.4.29", + "log 0.4.34", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -9608,7 +9744,7 @@ checksum = "1b1ffbcf9c6f6b99d386e7444eb608ba646ae452a36b39737deb9663b610f662" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -9638,7 +9774,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" dependencies = [ - "log 0.4.29", + "log 0.4.34", "once_cell", "tracing-core", ] @@ -9677,18 +9813,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.17", + "thiserror 2.0.21", "windows-sys 0.61.2", ] @@ -9725,7 +9861,7 @@ checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424" dependencies = [ "chrono", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.21", "ts-rs-macros", ] @@ -9737,29 +9873,40 @@ checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", "termcolor", ] [[package]] name = "tungstenite" -version = "0.26.2" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" dependencies = [ "bytes", "data-encoding", "http", "httparse", - "log 0.4.29", - "rand 0.9.5", + "log 0.4.34", + "rand 0.10.3", "rustls", "rustls-pki-types", - "sha1", - "thiserror 2.0.17", - "utf-8", + "sha1 0.11.0", + "thiserror 2.0.21", ] +[[package]] +name = "typed-arena" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b2228007eba4120145f785df0f6c92ea538f5a3635a612ecf4e334c8c1446d" + +[[package]] +name = "typed-path" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c" + [[package]] name = "typedmap" version = "0.6.0" @@ -9798,6 +9945,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -9895,12 +10051,12 @@ checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" [[package]] name = "universal-hash" -version = "0.5.1" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common", - "subtle", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -9924,7 +10080,7 @@ dependencies = [ "base64 0.22.1", "cookie_store", "flate2", - "log 0.4.29", + "log 0.4.34", "percent-encoding", "rustls", "rustls-pki-types", @@ -9945,7 +10101,7 @@ dependencies = [ "base64 0.22.1", "http", "httparse", - "log 0.4.29", + "log 0.4.34", ] [[package]] @@ -9972,7 +10128,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" dependencies = [ - "regex 1.11.1", + "regex 1.13.1", "serde", "unic-ucd-ident", "url", @@ -9990,12 +10146,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" -[[package]] -name = "utf8-width" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" - [[package]] name = "utf8-zero" version = "0.8.1" @@ -10016,14 +10166,14 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.17.0" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.4.3", "js-sys", - "rand 0.9.5", - "serde", + "rand 0.10.3", + "serde_core", "wasm-bindgen", ] @@ -10033,12 +10183,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "value-bag" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" - [[package]] name = "value-trait" version = "0.12.1" @@ -10125,9 +10269,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -10145,12 +10289,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "wasip2" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] @@ -10197,7 +10341,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -10210,19 +10354,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -10238,13 +10369,13 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.10" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe770181423e5fc79d3e2a7f4410b7799d5aab1de4372853de3c6aa13ca24121" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", - "rustix 0.38.44", + "rustix 1.1.5", "scoped-tls", "smallvec", "wayland-sys", @@ -10252,21 +10383,21 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.10" +version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978fa7c67b0847dbd6a9f350ca2569174974cd4082737054dbb7fbb79d7d9a61" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ "bitflags 2.11.0", - "rustix 0.38.44", + "rustix 1.1.5", "wayland-backend", "wayland-scanner", ] [[package]] name = "wayland-protocols" -version = "0.32.8" +version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "779075454e1e9a521794fed15886323ea0feda3f8b0fc1390f5398141310422a" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ "bitflags 2.11.0", "wayland-backend", @@ -10276,9 +10407,9 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.8" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cb6cdc73399c0e06504c437fe3cf886f25568dd5454473d565085b36d6a8bbf" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ "bitflags 2.11.0", "wayland-backend", @@ -10289,23 +10420,23 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.6" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "896fdafd5d28145fce7958917d69f2fd44469b1d4e861cb5961bcbeebc6d1484" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml 0.37.5", + "quick-xml 0.41.0", "quote", ] [[package]] name = "wayland-sys" -version = "0.31.6" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbcebb399c77d5aa9fa5db874806ee7b4eba4e73650948e8f93963f128896615" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "dlib", - "log 0.4.29", + "log 0.4.34", "pkg-config", ] @@ -10343,16 +10474,16 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.1.0" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f00bb839c1cf1e3036066614cbdcd035ecf215206691ea646aa3c60a24f68f2" +checksum = "d2c8ddfbbf66e501434657400268cefe51b95140a0266236415cda7627732df7" dependencies = [ - "core-foundation 0.10.1", - "jni", - "log 0.4.29", + "jni 0.22.4", + "log 0.4.34", "ndk-context", - "objc2 0.6.4", - "objc2-foundation 0.3.1", + "objc2 0.6.5", + "objc2-app-kit", + "objc2-foundation 0.3.2", "url", "web-sys", ] @@ -10441,7 +10572,7 @@ checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -10450,7 +10581,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" dependencies = [ - "thiserror 2.0.17", + "thiserror 2.0.21", "windows 0.61.1", "windows-core 0.61.2", ] @@ -10498,10 +10629,10 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "raw-window-handle", "windows-sys 0.59.0", "windows-version", @@ -10607,7 +10738,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -10618,7 +10749,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -11046,6 +11177,9 @@ name = "winnow" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] [[package]] name = "winreg" @@ -11056,16 +11190,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "winreg" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - [[package]] name = "winreg" version = "0.55.0" @@ -11077,26 +11201,32 @@ dependencies = [ ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "winreg" +version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ - "bitflags 2.11.0", + "cfg-if", + "windows-sys 0.61.2", ] [[package]] -name = "wl-clipboard-rs" -version = "0.9.2" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5ff8d0e60065f549fafd9d6cb626203ea64a798186c80d8e7df4f8af56baeb" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wl-clipboard-rs" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "959c7ac3b8afde864f7a7448ac16f29b2b83350b7eccbf77fb781c3999aa6848" dependencies = [ "libc", - "log 0.4.29", + "log 0.4.34", "os_pipe", - "rustix 0.38.44", - "tempfile", - "thiserror 2.0.17", + "rustix 1.1.5", + "thiserror 2.0.21", "tree_magic_mini", "wayland-backend", "wayland-client", @@ -11120,7 +11250,7 @@ dependencies = [ "block2 0.6.1", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", @@ -11128,22 +11258,22 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "objc2-ui-kit", "objc2-web-kit", "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", - "thiserror 2.0.17", + "thiserror 2.0.21", "url", "webkit2gtk", "webkit2gtk-sys", @@ -11154,15 +11284,6 @@ dependencies = [ "x11-dl", ] -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "x11" version = "2.21.0" @@ -11208,31 +11329,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d65cbf2f12c15564212d48f4e3dfb87923d25d611f2aed18f4cb23f0413d89e" dependencies = [ "libc", - "rustix 1.0.7", + "rustix 1.1.5", ] [[package]] name = "xxhash-rust" -version = "0.8.15" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" [[package]] name = "yaak" version = "0.1.0" dependencies = [ "async-trait", - "base64 0.22.1", + "base64 0.23.1", "chrono", - "log 0.4.29", - "md5 0.8.0", + "log 0.4.34", + "md5", "rusqlite", "serde_json", + "sha2 0.10.9", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.21", "tokio", "yaak-core", "yaak-crypto", + "yaak-filter", "yaak-http", "yaak-models", "yaak-plugins", @@ -11244,10 +11367,10 @@ dependencies = [ name = "yaak-api" version = "0.1.0" dependencies = [ - "log 0.4.29", - "reqwest 0.12.20", + "log 0.4.34", + "reqwest", "sysproxy", - "thiserror 2.0.17", + "thiserror 2.0.21", "yaak-common", ] @@ -11255,22 +11378,22 @@ dependencies = [ name = "yaak-app-client" version = "0.0.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "charset", "chrono", "cookie", "eventsource-client", "http", - "log 0.4.29", - "md5 0.8.0", + "log 0.4.34", + "md5", "mime_guess", "notify", "openssl-sys", "pretty_graphql", "r2d2", "r2d2_sqlite", - "rand 0.9.5", - "reqwest 0.12.20", + "rand 0.10.3", + "reqwest", "rlimit", "serde", "serde_json", @@ -11286,7 +11409,7 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-single-instance", "tauri-plugin-updater", - "thiserror 2.0.17", + "thiserror 2.0.21", "tokio", "tokio-stream", "tokio-tungstenite", @@ -11300,6 +11423,7 @@ dependencies = [ "yaak-common", "yaak-core", "yaak-crypto", + "yaak-filter", "yaak-fonts", "yaak-git", "yaak-grpc", @@ -11307,6 +11431,7 @@ dependencies = [ "yaak-license", "yaak-lifecycle", "yaak-mac-window", + "yaak-mcp", "yaak-models", "yaak-plugins", "yaak-rpc", @@ -11325,10 +11450,12 @@ dependencies = [ name = "yaak-app-proxy" version = "0.0.0" dependencies = [ - "log 0.4.29", + "log 0.4.34", "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-opener", "tauri-plugin-os", "yaak-mac-window", "yaak-proxy-lib", @@ -11342,40 +11469,43 @@ version = "0.1.0" dependencies = [ "arboard", "assert_cmd", - "base64 0.22.1", + "base64 0.23.1", "clap", "console 0.15.11", - "dirs", + "dirs 7.0.0", "env_logger", "futures", "hex", "include_dir", "inquire", "keyring", - "log 0.4.29", + "log 0.4.34", "oxc_resolver", "predicates", - "rand 0.8.7", - "reqwest 0.12.20", + "rand 0.10.3", + "reqwest", + "rmcp", "rolldown", "schemars 0.8.22", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "tokio", "walkdir", "webbrowser", "yaak", "yaak-api", + "yaak-common", "yaak-core", "yaak-crypto", "yaak-http", "yaak-lifecycle", + "yaak-mcp", "yaak-models", "yaak-plugins", "yaak-templates", - "zip", + "zip 9.0.5", ] [[package]] @@ -11384,7 +11514,7 @@ version = "0.0.0" dependencies = [ "serde_json", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.21", "tokio", "yaak", "yaak-core", @@ -11399,6 +11529,7 @@ dependencies = [ name = "yaak-common" version = "0.1.0" dependencies = [ + "dirs 7.0.0", "serde_json", "tokio", ] @@ -11407,7 +11538,7 @@ dependencies = [ name = "yaak-core" version = "0.0.0" dependencies = [ - "thiserror 2.0.17", + "thiserror 2.0.21", ] [[package]] @@ -11415,12 +11546,12 @@ name = "yaak-crypto" version = "0.1.0" dependencies = [ "base32", - "base64 0.22.1", + "base64 0.23.1", "chacha20poly1305", "keyring", - "log 0.4.29", + "log 0.4.34", "serde", - "thiserror 2.0.17", + "thiserror 2.0.21", "yaak-models", ] @@ -11429,9 +11560,9 @@ name = "yaak-database" version = "0.1.0" dependencies = [ "chrono", - "getrandom 0.2.16", + "getrandom 0.3.4", "include_dir", - "log 0.4.29", + "log 0.4.34", "nanoid", "r2d2", "r2d2_sqlite", @@ -11440,11 +11571,22 @@ dependencies = [ "sea-query-rusqlite", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.21", "ts-rs", "uuid", ] +[[package]] +name = "yaak-filter" +version = "0.1.0" +dependencies = [ + "fancy-regex 0.19.2", + "indexmap 2.14.2", + "serde_json", + "sxd-document", + "sxd-xpath", +] + [[package]] name = "yaak-fonts" version = "0.1.0" @@ -11453,7 +11595,7 @@ dependencies = [ "serde", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.21", "ts-rs", ] @@ -11463,12 +11605,12 @@ version = "0.1.0" dependencies = [ "chrono", "git2", - "log 0.4.29", + "log 0.4.34", "serde", "serde_json", "serde_yaml", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.21", "tokio", "ts-rs", "url", @@ -11486,14 +11628,14 @@ dependencies = [ "dunce", "hyper-rustls", "hyper-util", - "log 0.4.29", - "md5 0.7.0", + "log 0.4.34", + "md5", "prost", "prost-reflect", "prost-types", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.21", "tokio", "tokio-stream", "tonic", @@ -11510,21 +11652,21 @@ version = "0.1.0" dependencies = [ "async-compression", "async-trait", - "brotli 7.0.0", + "brotli 9.1.1", "bytes", "cookie", "flate2", "futures-util", "http-body", "hyper-util", - "log 0.4.29", + "log 0.4.34", "mime_guess", "native-tls", - "reqwest 0.12.20", + "reqwest", "serde", "serde_json", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.21", "tokio", "tokio-util", "tower-service", @@ -11542,13 +11684,13 @@ name = "yaak-license" version = "0.1.0" dependencies = [ "chrono", - "log 0.4.29", - "reqwest 0.12.20", + "log 0.4.34", + "reqwest", "serde", "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.21", "ts-rs", "yaak-api", "yaak-common", @@ -11559,7 +11701,7 @@ dependencies = [ name = "yaak-lifecycle" version = "0.0.0" dependencies = [ - "log 0.4.29", + "log 0.4.34", "yaak-models", ] @@ -11569,13 +11711,32 @@ version = "0.1.0" dependencies = [ "cocoa", "csscolorparser", - "log 0.4.29", + "log 0.4.34", "objc", - "rand 0.9.5", + "rand 0.10.3", "tauri", "tauri-plugin", ] +[[package]] +name = "yaak-mcp" +version = "0.1.0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "log 0.4.34", + "reqwest", + "rmcp", + "serde", + "serde_json", + "tokio", + "tokio-util", + "yaak-models", + "yaak-plugins", +] + [[package]] name = "yaak-models" version = "0.1.0" @@ -11583,7 +11744,7 @@ dependencies = [ "chrono", "hex", "include_dir", - "log 0.4.29", + "log 0.4.34", "nanoid", "r2d2", "r2d2_sqlite", @@ -11593,8 +11754,8 @@ dependencies = [ "sea-query-rusqlite", "serde", "serde_json", - "sha2", - "thiserror 2.0.17", + "sha2 0.10.9", + "thiserror 2.0.21", "ts-rs", "urlencoding", "yaak-core", @@ -11602,25 +11763,42 @@ dependencies = [ "yaak-templates", ] +[[package]] +name = "yaak-playground" +version = "0.1.0" +dependencies = [ + "axum", + "bytes", + "clap", + "env_logger", + "log 0.4.34", + "serde", + "serde_json", + "tokio", + "tower", + "tower-http 0.7.1", + "uuid", +] + [[package]] name = "yaak-plugins" version = "0.1.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "chrono", "dunce", "futures-util", "hex", "keyring", - "log 0.4.29", - "md5 0.7.0", + "log 0.4.34", + "md5", "path-slash", - "rand 0.9.5", - "reqwest 0.12.20", + "rand 0.10.3", + "reqwest", "serde", "serde_json", - "sha2", - "thiserror 2.0.17", + "sha2 0.10.9", + "thiserror 2.0.21", "tokio", "tokio-tungstenite", "ts-rs", @@ -11628,7 +11806,7 @@ dependencies = [ "yaak-crypto", "yaak-models", "yaak-templates", - "zip-extract", + "zip 9.0.5", ] [[package]] @@ -11636,42 +11814,85 @@ name = "yaak-proxy" version = "0.1.0" dependencies = [ "bytes", + "futures-util", "http", "http-body-util", "hyper", "hyper-util", + "log 0.4.34", "pem", "rcgen", + "reqwest", "rustls", "rustls-native-certs", + "tempfile", + "time", "tokio", "tokio-rustls", + "tokio-tungstenite", +] + +[[package]] +name = "yaak-proxy-helper" +version = "0.0.0" +dependencies = [ + "core-foundation 0.10.1", + "libc", + "security-framework 3.7.0", + "serde", + "serde_json", + "tempfile", + "ts-rs", ] [[package]] name = "yaak-proxy-lib" version = "0.0.0" dependencies = [ + "base64 0.23.1", + "bytes", "chrono", + "encoding_rs", + "flate2", + "http-body-util", + "hyper", + "hyper-util", "include_dir", - "log 0.4.29", + "libc", + "log 0.4.34", + "objc2 0.6.5", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "objc2-service-management", + "plist", "r2d2", "r2d2_sqlite", + "reqwest", "rusqlite", "sea-query", "serde", "serde_json", + "tempfile", + "tokio", "ts-rs", + "url", + "yaak-common", "yaak-database", + "yaak-filter", + "yaak-http", + "yaak-lifecycle", + "yaak-models", "yaak-proxy", + "yaak-proxy-helper", "yaak-rpc", + "yaak-templates", ] [[package]] name = "yaak-rpc" version = "0.0.0" dependencies = [ - "log 0.4.29", + "log 0.4.34", "serde", "serde_json", "ts-rs", @@ -11707,14 +11928,15 @@ version = "0.1.0" dependencies = [ "chrono", "hex", - "log 0.4.29", + "log 0.4.34", "notify", "serde", "serde_json", "serde_path_to_error", "serde_yaml", - "sha1", - "thiserror 2.0.17", + "sha1 0.11.0", + "tempfile", + "thiserror 2.0.21", "tokio", "ts-rs", "yaak-models", @@ -11726,9 +11948,9 @@ version = "0.1.0" dependencies = [ "dark-light", "dispatch2", - "log 0.4.29", + "log 0.4.34", "objc2-app-kit", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "tauri", ] @@ -11736,7 +11958,7 @@ dependencies = [ name = "yaak-tauri-utils" version = "0.1.0" dependencies = [ - "regex 1.11.1", + "regex 1.13.1", "tauri", "yaak-core", ] @@ -11745,12 +11967,12 @@ dependencies = [ name = "yaak-templates" version = "0.1.0" dependencies = [ - "base64 0.22.1", - "log 0.4.29", + "base64 0.23.1", + "log 0.4.34", "serde", "serde-wasm-bindgen", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.21", "tokio", "ts-rs", "wasm-bindgen", @@ -11760,17 +11982,17 @@ dependencies = [ name = "yaak-tls" version = "0.1.0" dependencies = [ - "log 0.4.29", + "log 0.4.34", "p12", "pem", "rustls", "rustls-pemfile", "rustls-platform-verifier", "serde", - "thiserror 2.0.17", + "thiserror 2.0.21", "url", "yaak-models", - "yasna", + "yasna 0.6.0", ] [[package]] @@ -11779,7 +12001,7 @@ version = "0.1.0" dependencies = [ "console_error_panic_hook", "js-sys", - "log 0.4.29", + "log 0.4.34", "serde", "serde-wasm-bindgen", "serde_json", @@ -11799,16 +12021,18 @@ version = "0.1.0" dependencies = [ "async-trait", "axum", - "base64 0.22.1", + "base64 0.23.1", "bytes", "clap", "env_logger", "futures-util", - "log 0.4.29", + "log 0.4.34", "serde", "serde_json", + "tempfile", "tokio", - "tower-http", + "tower", + "tower-http 0.7.1", "ts-rs", "url", "uuid", @@ -11820,9 +12044,9 @@ dependencies = [ name = "yaak-window" version = "0.1.0" dependencies = [ - "log 0.4.29", - "md5 0.8.0", - "rand 0.9.5", + "log 0.4.34", + "md5", + "rand 0.10.3", "serde", "serde_json", "tauri", @@ -11835,11 +12059,11 @@ version = "0.1.0" dependencies = [ "futures-util", "http", - "log 0.4.29", - "md5 0.8.0", + "log 0.4.34", + "md5", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.21", "tokio", "tokio-tungstenite", "url", @@ -11864,6 +12088,12 @@ dependencies = [ "time", ] +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" + [[package]] name = "yoke" version = "0.8.0" @@ -11884,15 +12114,15 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", "synstructure", ] [[package]] name = "zbus" -version = "5.11.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-executor", @@ -11904,19 +12134,21 @@ dependencies = [ "async-trait", "blocking", "enumflags2", - "event-listener 5.4.0", + "event-listener", "futures-core", "futures-lite", "hex", - "nix 0.30.1", + "libc", "ordered-stream", + "rustix 1.1.5", "serde", "serde_repr", "tokio", "tracing", "uds_windows", - "windows-sys 0.60.2", - "winnow 0.7.10", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.2", "zbus_macros", "zbus_names", "zvariant", @@ -11924,14 +12156,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.11.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate 3.3.0", "proc-macro2", "quote", - "syn 2.0.101", + "syn 3.0.3", "zbus_names", "zvariant", "zvariant_utils", @@ -11939,16 +12171,24 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.2.0" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", - "static_assertions", - "winnow 0.7.10", + "winnow 1.0.2", "zvariant", ] +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.25" @@ -11966,7 +12206,7 @@ checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -11986,7 +12226,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", "synstructure", ] @@ -12007,7 +12247,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] @@ -12040,87 +12280,96 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.119", ] [[package]] name = "zip" -version = "4.0.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "153a6fff49d264c4babdcfa6b4d534747f520e56e8f0f384f3b808c4b64cc1fd" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.2", + "memchr", +] + +[[package]] +name = "zip" +version = "9.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" dependencies = [ "aes", - "arbitrary", - "bzip2 0.5.2", - "constant_time_eq 0.3.1", + "bzip2", + "constant_time_eq 0.6.1", "crc32fast", "deflate64", "flate2", - "getrandom 0.3.3", - "hmac", - "indexmap 2.14.0", - "liblzma", + "getrandom 0.4.3", + "hmac 0.13.0", + "indexmap 2.14.2", + "lzma-rust2", "memchr", + "nix 0.31.3", "pbkdf2", - "sha1", + "ppmd-rust", + "sha1 0.11.0", "time", + "typed-path", "zeroize", "zopfli", "zstd", ] [[package]] -name = "zip-extract" -version = "0.4.0" +name = "zlib-rs" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aed5f10c571472911e37d8f7601a8dfba52b4f7f73a344015291b82ab292faf6" -dependencies = [ - "log 0.4.29", - "thiserror 2.0.17", - "zip", -] +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] -name = "zlib-rs" -version = "0.5.1" +name = "zmij" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" dependencies = [ "bumpalo", "crc32fast", - "log 0.4.29", + "log 0.4.34", "simd-adler32", ] [[package]] name = "zstd" -version = "0.13.3" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +checksum = "bf06bd8162af0734b344780deb55b42a2429ae430870d13fcc12f238e880fe6e" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "7.2.4" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +checksum = "ae42c0555055784c70058d19ba8e275528e8a99a706684868ace5da4e716a4ab" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.15+zstd.1.5.7" +version = "2.1.0+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" dependencies = [ "cc", "pkg-config", @@ -12128,42 +12377,42 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.5.3" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d30786f75e393ee63a21de4f9074d4c038d52c5b1bb4471f955db249f9dffb1" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", "serde", "url", - "winnow 0.7.10", + "winnow 1.0.2", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.5.3" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75fda702cd42d735ccd48117b1630432219c0e9616bf6cb0f8350844ee4d9580" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ "proc-macro-crate 3.3.0", "proc-macro2", "quote", - "syn 2.0.101", + "syn 3.0.3", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.2.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16edfee43e5d7b553b77872d99bc36afdda75c223ca7ad5e3fbecd82ca5fc34" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", "serde", - "static_assertions", - "syn 2.0.101", - "winnow 0.7.10", + "syn 3.0.3", + "winnow 1.0.2", ] diff --git a/Cargo.toml b/Cargo.toml index 772e679de..40557a8ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,10 +11,12 @@ members = [ "crates/yaak-core", "crates/yaak-common", "crates/yaak-crypto", + "crates/yaak-filter", "crates/yaak-git", "crates/yaak-grpc", "crates/yaak-http", "crates/yaak-lifecycle", + "crates/yaak-mcp", "crates/yaak-models", "crates/yaak-plugins", "crates/yaak-sse", @@ -29,6 +31,7 @@ members = [ "crates-proxy/yaak-proxy-lib", # Server crates (the browser tier's hosted send executor) "crates-server/yaak-web", + "crates-server/yaak-playground", # CLI crates "crates-cli/yaak-cli", # Tauri-specific crates @@ -37,6 +40,7 @@ members = [ "crates-tauri/yaak-fonts", "crates-tauri/yaak-license", "crates-tauri/yaak-mac-window", + "crates-tauri/yaak-proxy-helper", "crates-tauri/yaak-system-appearance", "crates-tauri/yaak-tauri-utils", "crates-tauri/yaak-window", @@ -47,9 +51,16 @@ chrono = "0.4.42" hex = "0.4.3" keyring = "3.6.3" log = "0.4.29" -reqwest = "0.12.20" +reqwest = { version = "0.13.5", default-features = false, features = [ + # reqwest 0.13 switched `default-tls` from native-tls to rustls; keep the + # rest of the old default set and stay on native-tls. + "charset", + "http2", + "system-proxy", + "native-tls", +] } rustls = { version = "0.23.34", default-features = false } -rustls-platform-verifier = "0.6.2" +rustls-platform-verifier = "0.7.1" schemars = { version = "0.8.22", features = ["chrono"] } serde = "1.0.228" serde_json = "1.0.145" @@ -77,10 +88,12 @@ yaak = { path = "crates/yaak" } yaak-commands = { path = "crates/yaak-commands" } yaak-common = { path = "crates/yaak-common" } yaak-crypto = { path = "crates/yaak-crypto" } +yaak-filter = { path = "crates/yaak-filter" } 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-mcp = { path = "crates/yaak-mcp" } yaak-models = { path = "crates/yaak-models" } yaak-plugins = { path = "crates/yaak-plugins" } yaak-sse = { path = "crates/yaak-sse" } @@ -98,6 +111,7 @@ yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" } yaak-fonts = { path = "crates-tauri/yaak-fonts" } yaak-license = { path = "crates-tauri/yaak-license" } yaak-mac-window = { path = "crates-tauri/yaak-mac-window" } +yaak-proxy-helper = { path = "crates-tauri/yaak-proxy-helper" } yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" } yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" } yaak-window = { path = "crates-tauri/yaak-window" } diff --git a/Dockerfile.playground b/Dockerfile.playground new file mode 100644 index 000000000..e631eb190 --- /dev/null +++ b/Dockerfile.playground @@ -0,0 +1,15 @@ +# syntax=docker/dockerfile:1 +# The playground API behind yaak.run. See crates-server/yaak-playground/README.md. + +FROM rust:1-bookworm AS build +WORKDIR /app +COPY . . +RUN cargo build --release -p yaak-playground + +FROM debian:bookworm-slim +COPY --from=build /app/target/release/yaak-playground /usr/local/bin/yaak-playground +ENV HOST=0.0.0.0 +ENV PORT=8080 +EXPOSE 8080 +USER nobody +CMD ["yaak-playground"] diff --git a/Dockerfile.web b/Dockerfile.web index dba755ccc..460b5a1e8 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -38,7 +38,10 @@ 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 +# 0.0.0.0 so the port is reachable from outside the container. PORT is the variable a +# platform-as-a-service assigns, so a deploy that sets it needs nothing else changed. +ENV HOST=0.0.0.0 +ENV PORT=8080 EXPOSE 8080 USER nobody # Overriding the command (dropping --serve) leaves the stateless send executor: diff --git a/README.md b/README.md index df97f9612..7778b212c 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@

- User avatar: MVST-Solutions  User avatar: dharsanb  User avatar: railwayapp  User avatar: caseyamcl  User avatar: bytebase  User avatar:    + User avatar: MVST-Solutions  User avatar: dharsanb  User avatar: railwayapp  User avatar: caseyamcl  User avatar: bytebase  User avatar: rauchg  User avatar:   

User avatar: seanwash  User avatar: jerath  User avatar: itsa-sh  User avatar: dmmulroy  User avatar: timcole  User avatar: VLZH  User avatar: terasaka2k  User avatar: andriyor  User avatar: majudhu  User avatar: axelrindle  User avatar: jirizverina  User avatar: chip-well  User avatar: GRAYAH  User avatar: flashblaze  User avatar: Frostist  User avatar: PurplProto   @@ -25,6 +25,10 @@ ![Yaak API Client](https://yaak.app/static/screenshot.png) +## Install + +Download Yaak for macOS, Windows, or Linux from [yaak.app/download](https://yaak.app/download). Package managers and other install methods are in the [installation docs](https://yaak.app/docs/getting-started/installation). + ## Features Yaak is an offline-first API client designed to stay out of your way while giving you everything you need when you need it. @@ -68,3 +72,9 @@ Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight - [Yaak vs Postman](https://yaak.app/alternatives/postman) - [Yaak vs Bruno](https://yaak.app/alternatives/bruno) - [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia) + +## License + +The source is [MIT licensed](LICENSE) and can be built and run for free, for both personal and commercial use. + +The prebuilt binaries follow Yaak's [pricing terms](https://yaak.app/pricing): free for personal use, license required for commercial use. diff --git a/apps/yaak-client/commands/commands.test.tsx b/apps/yaak-client/commands/commands.test.tsx new file mode 100644 index 000000000..eef1f85ca --- /dev/null +++ b/apps/yaak-client/commands/commands.test.tsx @@ -0,0 +1,148 @@ +import type { FunctionComponent, ReactElement } from "react"; +import { beforeEach, expect, test, vi } from "vite-plus/test"; +import type { SyncOp } from "@yaakapp-internal/sync/bindings/gen_sync"; +import type { DialogInstance } from "../components/Dialogs"; +import { syncWorkspace } from "./commands"; + +const mocks = vi.hoisted(() => ({ + calculateSync: vi.fn(), + applySync: vi.fn(), + showDialog: vi.fn<(dialog: DialogInstance) => void>(), + hideDialog: vi.fn(), + showToast: vi.fn(), +})); + +vi.mock("@yaakapp-internal/sync", () => mocks); +vi.mock("@yaakapp-internal/models", () => ({ + createWorkspaceModel: vi.fn(), + modelTypeLabel: () => "Request", +})); +vi.mock("@yaakapp-internal/ui", () => ({ + Banner: "div", + InlineCode: "code", + Table: "table", + TableBody: "tbody", + TableCell: "td", + TableHead: "thead", + TableHeaderCell: "th", + TableRow: "tr", + TruncatedWideTableCell: "td", +})); +vi.mock("../components/core/Button", () => ({ Button: "button" })); +vi.mock("../hooks/useActiveWorkspace", () => ({ activeWorkspaceIdAtom: null })); +vi.mock("../lib/dialog", () => mocks); +vi.mock("../lib/jotai", () => ({ jotaiStore: { get: vi.fn() } })); +vi.mock("../lib/prompt", () => ({ showPrompt: vi.fn() })); +vi.mock("../lib/resolvedModelName", () => ({ resolvedModelNameWithFolders: () => "Request" })); +vi.mock("../lib/toast", () => mocks); + +const staleToast = expect.objectContaining({ + message: "Files changed while syncing. Review the changes again.", +}); + +const params = { workspaceId: "wk_test", syncDir: "/sync" }; +const deletion = { + type: "dbDelete", + model: { id: "rq_test", model: "http_request" }, + state: {}, +} as Extract; +const update = { + type: "dbUpdate", + model: { id: "rq_test", model: "http_request" }, + fs: { model: { id: "rq_test", model: "http_request", name: "Updated" } }, + state: {}, +} as Extract; + +async function submit(dialog: DialogInstance) { + const render = dialog.render as FunctionComponent<{ hide: () => void }>; + const form = render({ hide: () => mocks.hideDialog(dialog.id) }) as ReactElement<{ + onSubmit: (event: { preventDefault: () => void }) => Promise; + }>; + await form.props.onSubmit({ preventDefault: vi.fn() }); +} + +function latestDialog() { + const dialog = mocks.showDialog.mock.calls.at(-1)?.[0]; + if (dialog == null) throw new Error("Expected a sync dialog"); + return dialog; +} + +beforeEach(() => { + vi.resetAllMocks(); + mocks.applySync.mockResolvedValue(true); +}); + +test("closes an obsolete dialog when the restored file leaves no changes", async () => { + mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([]); + await syncWorkspace.mutateAsync(params); + mocks.applySync.mockResolvedValueOnce(false); + + await submit(latestDialog()); + + expect(mocks.applySync).toHaveBeenCalledTimes(1); + expect(mocks.applySync).toHaveBeenCalledWith(params.workspaceId, params.syncDir, [deletion]); + expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync"); + expect(mocks.showDialog).toHaveBeenCalledTimes(1); +}); + +test("requires another confirmation for a changed plan", async () => { + mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([update]); + await syncWorkspace.mutateAsync(params); + const original = latestDialog(); + mocks.applySync.mockResolvedValueOnce(false); + + await submit(original); + + expect(mocks.applySync).toHaveBeenCalledTimes(1); + expect(mocks.showDialog).toHaveBeenCalledTimes(2); + expect(mocks.showToast).toHaveBeenCalledWith(staleToast); + expect(mocks.hideDialog).not.toHaveBeenCalled(); + await submit(latestDialog()); + expect(mocks.applySync).toHaveBeenLastCalledWith(params.workspaceId, params.syncDir, [update]); + expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync"); +}); + +test("applies an unchanged reviewed plan and closes the dialog", async () => { + mocks.calculateSync.mockResolvedValue([update]); + await syncWorkspace.mutateAsync(params); + await submit(latestDialog()); + + expect(mocks.calculateSync).toHaveBeenCalledTimes(1); + expect(mocks.applySync).toHaveBeenCalledTimes(1); + expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync"); +}); + +test("forced sync retries the current plan without requesting another confirmation", async () => { + mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([update]); + mocks.applySync.mockResolvedValueOnce(false); + + await syncWorkspace.mutateAsync({ ...params, force: true }); + + expect(mocks.applySync).toHaveBeenCalledTimes(2); + expect(mocks.applySync).toHaveBeenLastCalledWith(params.workspaceId, params.syncDir, [update]); + expect(mocks.showDialog).not.toHaveBeenCalled(); +}); + +test("outbound-only changes clear the old dialog and sync automatically", async () => { + const outbound = { type: "fsUpdate", model: update.model, state: {} } as SyncOp; + mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([outbound]); + await syncWorkspace.mutateAsync(params); + await syncWorkspace.mutateAsync(params); + + expect(mocks.applySync).toHaveBeenCalledWith(params.workspaceId, params.syncDir, [outbound]); + expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync"); + expect(mocks.showDialog).toHaveBeenCalledTimes(1); +}); + +test("stops retrying a plan that keeps going stale", async () => { + mocks.calculateSync.mockResolvedValue([update]); + mocks.applySync.mockResolvedValue(false); + + await syncWorkspace.mutateAsync({ ...params, force: true }); + + expect(mocks.calculateSync).toHaveBeenCalledTimes(3); + expect(mocks.applySync).toHaveBeenCalledTimes(3); + expect(mocks.showToast).toHaveBeenCalledTimes(1); + expect(mocks.showToast).toHaveBeenCalledWith(staleToast); + expect(mocks.showDialog).not.toHaveBeenCalled(); +}); diff --git a/apps/yaak-client/commands/commands.tsx b/apps/yaak-client/commands/commands.tsx index 40618a77a..dc9a7adbc 100644 --- a/apps/yaak-client/commands/commands.tsx +++ b/apps/yaak-client/commands/commands.tsx @@ -14,11 +14,12 @@ import { } from "@yaakapp-internal/ui"; import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace"; import { createFastMutation } from "../hooks/useFastMutation"; -import { showDialog } from "../lib/dialog"; +import { hideDialog, showDialog } from "../lib/dialog"; import { jotaiStore } from "../lib/jotai"; -import { pluralizeCount } from "../lib/pluralize"; import { showPrompt } from "../lib/prompt"; import { resolvedModelNameWithFolders } from "../lib/resolvedModelName"; +import { showToast } from "../lib/toast"; +import { pluralizeCount } from "@yaakapp-internal/lib/pluralize"; export const createFolder = createFastMutation< string | null, @@ -52,24 +53,48 @@ export const createFolder = createFastMutation< }, }); +// Files can keep changing under a plan, so stop recalculating after a few stale applies. +const MAX_SYNC_ATTEMPTS = 3; + +function showStaleSyncToast() { + showToast({ + id: "sync-stale", + color: "warning", + message: "Files changed while syncing. Review the changes again.", + }); +} + export const syncWorkspace = createFastMutation< void, void, - { workspaceId: string; syncDir: string; force?: boolean } + { workspaceId: string; syncDir: string; force?: boolean; attempt?: number } >({ mutationKey: [], - mutationFn: async ({ workspaceId, syncDir, force }) => { + mutationFn: async ({ workspaceId, syncDir, force, attempt = 1 }) => { const ops = (await calculateSync(workspaceId, syncDir)) ?? []; if (ops.length === 0) { console.log("Nothing to sync", workspaceId, syncDir); + hideDialog("commit-sync"); return; } console.log("Syncing workspace", workspaceId, syncDir, ops); const dbOps = ops.filter((o) => o.type.startsWith("db")); - if (dbOps.length === 0) { - await applySync(workspaceId, syncDir, ops); + const apply = async () => { + if (await applySync(workspaceId, syncDir, ops)) { + hideDialog("commit-sync"); + } else if (attempt < MAX_SYNC_ATTEMPTS) { + await syncWorkspace.mutateAsync({ workspaceId, syncDir, force, attempt: attempt + 1 }); + } else { + hideDialog("commit-sync"); + showStaleSyncToast(); + } + }; + + if (dbOps.length === 0 || force) { + hideDialog("commit-sync"); + await apply(); return; } @@ -79,9 +104,8 @@ export const syncWorkspace = createFastMutation< console.log("Directory changes detected", { dbOps, ops }); - if (force) { - await applySync(workspaceId, syncDir, ops); - return; + if (attempt > 1) { + showStaleSyncToast(); } showDialog({ @@ -93,8 +117,7 @@ export const syncWorkspace = createFastMutation< className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)_auto] gap-3" onSubmit={async (e) => { e.preventDefault(); - await applySync(workspaceId, syncDir, ops); - hide(); + await apply(); }} > {isDeletingWorkspace ? ( diff --git a/apps/yaak-client/commands/createEnvironment.tsx b/apps/yaak-client/commands/createEnvironment.tsx index 80f68e10f..acd81efa7 100644 --- a/apps/yaak-client/commands/createEnvironment.tsx +++ b/apps/yaak-client/commands/createEnvironment.tsx @@ -6,11 +6,12 @@ import { showDialog } from "../lib/dialog"; import { jotaiStore } from "../lib/jotai"; import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams"; -export const createSubEnvironmentAndActivate = createFastMutation< - string | null, - unknown, - Environment | null ->({ +/** + * Prompt for and create a sub-environment of the given base environment, resolving with the new + * environment's ID (or null if the dialog was dismissed). This does not change the workspace's + * active environment. + */ +export const createSubEnvironment = createFastMutation({ mutationKey: ["create_environment"], mutationFn: async (baseEnvironment) => { if (baseEnvironment == null) { @@ -41,6 +42,17 @@ export const createSubEnvironmentAndActivate = createFastMutation< }); }); }, +}); + +/** Same as {@link createSubEnvironment}, but also makes the new environment the active one. */ +export const createSubEnvironmentAndActivate = createFastMutation< + string | null, + unknown, + Environment | null +>({ + mutationKey: ["create_environment_and_activate"], + mutationFn: (baseEnvironment) => createSubEnvironment.mutateAsync(baseEnvironment), + disableToastError: true, // Already shown by createSubEnvironment onSuccess: async (environmentId) => { if (environmentId == null) { return; // Was not created diff --git a/apps/yaak-client/commands/moveToWorkspace.tsx b/apps/yaak-client/commands/moveToWorkspace.tsx index 3428a5805..c88432937 100644 --- a/apps/yaak-client/commands/moveToWorkspace.tsx +++ b/apps/yaak-client/commands/moveToWorkspace.tsx @@ -3,9 +3,9 @@ import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-intern import { MoveToWorkspaceDialog } from "../components/MoveToWorkspaceDialog"; import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace"; import { createFastMutation } from "../hooks/useFastMutation"; -import { pluralizeCount } from "../lib/pluralize"; import { showDialog } from "../lib/dialog"; import { jotaiStore } from "../lib/jotai"; +import { pluralizeCount } from "@yaakapp-internal/lib/pluralize"; export const moveToWorkspace = createFastMutation({ mutationKey: ["move_workspace"], diff --git a/apps/yaak-client/commands/openWorkspaceFromSyncDir.tsx b/apps/yaak-client/commands/openWorkspaceFromSyncDir.tsx index 9c5b6d2f0..b12d90d84 100644 --- a/apps/yaak-client/commands/openWorkspaceFromSyncDir.tsx +++ b/apps/yaak-client/commands/openWorkspaceFromSyncDir.tsx @@ -17,7 +17,7 @@ export const openWorkspaceFromSyncDir = createFastMutation({ return; } - await applySync(workspace.id, dir, ops); + await applySync(workspace.id, dir, ops, { fsOnly: true }); await router.navigate({ to: "/workspaces/$workspaceId", diff --git a/apps/yaak-client/components/ClientEditorHost.tsx b/apps/yaak-client/components/ClientEditorHost.tsx new file mode 100644 index 000000000..48151927e --- /dev/null +++ b/apps/yaak-client/components/ClientEditorHost.tsx @@ -0,0 +1,99 @@ +import { platform } from "@yaakapp-internal/platform"; +import { + Banner, + type EditorHost, + EditorHostProvider, + EmptyStateText, + type HostBodyViewerArgs, + type HttpBodyLoader, + LoadingIcon, + type StoredValue, + type StoredValueNamespace, +} from "@yaakapp-internal/ui"; +import { lazy, type ReactNode, Suspense } from "react"; +import { useKeyValue } from "../hooks/useKeyValue"; +import { copyToClipboard } from "../lib/copy"; +import { showDialog } from "../lib/dialog"; +import { showPrompt } from "../lib/prompt"; +import { useDetailsBannerStoredValue } from "./core/DetailsBanner"; +import { Editor } from "./core/Editor/LazyEditor"; +import { Input } from "./core/Input"; +import { ErrorBoundary } from "./ErrorBoundary"; +import { MultipartViewer } from "./responseViewers/MultipartViewer"; +import { TextViewer } from "./responseViewers/TextViewer"; +import { SelectFile } from "./SelectFile"; + +const PdfViewer = lazy(() => + import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })), +); + +function useClientStoredValue( + key: string | string[] | null, + fallback: T, + namespace: StoredValueNamespace = "global", +) { + // A null key is never written by the shared components, so any stable key will do + const { value, set } = useKeyValue({ namespace, key: key ?? ["tabs", "default"], fallback }); + return [value ?? fallback, set] as const; +} + +function renderBodyViewer({ source, mimeType, body, complete }: HostBodyViewerArgs) { + if (!mimeType?.match(/pdf/i)) return null; + return source === "response" ? ( + + ) : ( + + ); +} + +/** Waits for the whole response, then loads the PDF from its file */ +function PdfResponseBody({ body, complete }: { body: HttpBodyLoader; complete: boolean }) { + const bodyUrl = body.useUrl(complete); + + if (!complete || bodyUrl.isLoading) { + return ( + + + + ); + } + if (bodyUrl.error) { + return {String(bodyUrl.error)}; + } + if (bodyUrl.data == null) { + return

Empty response body
; + } + return ; +} + +function PdfRequestBody({ body }: { body: HttpBodyLoader }) { + const bytes = body.useBytes(); + if (bytes.data == null) return null; + return ( + }> + + + ); +} + +const clientEditorHost: EditorHost = { + Editor, + Input, + SelectFile, + showDialog, + showPrompt, + basename: (path) => platform.files.basename(path), + useStoredValue: useClientStoredValue, + useDetailsBannerStoredValue, + ErrorBoundary, + TextViewer, + MultipartViewer, + renderBodyViewer, + openUrl: (url) => platform.openUrl(url), + copy: (text) => copyToClipboard(text, { disableToast: true }), +}; + +/** Gives the shared editing components the client's templated editor, dialogs and storage */ +export function ClientEditorHost({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/yaak-client/components/CloneGitRepositoryDialog.tsx b/apps/yaak-client/components/CloneGitRepositoryDialog.tsx index 160211018..dcba92539 100644 --- a/apps/yaak-client/components/CloneGitRepositoryDialog.tsx +++ b/apps/yaak-client/components/CloneGitRepositoryDialog.tsx @@ -1,12 +1,12 @@ import { gitClone } from "@yaakapp-internal/git"; import { Banner, VStack } from "@yaakapp-internal/ui"; -import { useState } from "react"; +import { useId, useState } from "react"; import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir"; import { appInfo } from "../lib/appInfo"; import { CommercialUseBanner } from "./CommercialUseBanner"; import { showErrorToast } from "../lib/toast"; -import { Button } from "./core/Button"; import { Checkbox } from "./core/Checkbox"; +import { DialogFooter } from "./core/Dialog"; import { IconButton } from "./core/IconButton"; import { PlainInput } from "./core/PlainInput"; import { promptCredentials } from "./git/credentials"; @@ -29,6 +29,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) { const [subdirectory, setSubdirectory] = useState(""); const [isCloning, setIsCloning] = useState(false); const [error, setError] = useState(null); + const formId = useId(); const repoName = extractRepoName(url); const sep = getPathSeparator(baseDirectory); @@ -83,7 +84,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) { }; return ( - + {error && ( {error} @@ -132,15 +133,17 @@ export function CloneGitRepositoryDialog({ hide }: Props) { /> )} - + ); } diff --git a/apps/yaak-client/components/ConfirmLargeRequestBody.tsx b/apps/yaak-client/components/ConfirmLargeRequestBody.tsx index f8acf535e..31956044b 100644 --- a/apps/yaak-client/components/ConfirmLargeRequestBody.tsx +++ b/apps/yaak-client/components/ConfirmLargeRequestBody.tsx @@ -1,12 +1,11 @@ import type { HttpRequest } from "@yaakapp-internal/models"; import { patchModel } from "@yaakapp-internal/models"; -import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui"; +import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui"; import type { ReactNode } from "react"; import { useToggle } from "../hooks/useToggle"; import { showConfirm } from "../lib/confirm"; import { Button } from "./core/Button"; import { Link } from "./core/Link"; -import { SizeTag } from "./core/SizeTag"; interface Props { children: ReactNode; diff --git a/apps/yaak-client/components/ConfirmLargeResponse.tsx b/apps/yaak-client/components/ConfirmLargeResponse.tsx deleted file mode 100644 index 90bb2c850..000000000 --- a/apps/yaak-client/components/ConfirmLargeResponse.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import type { HttpResponse } from "@yaakapp-internal/models"; -import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui"; -import { type ReactNode, useMemo } from "react"; -import { useSaveResponse } from "../hooks/useSaveResponse"; -import { useToggle } from "../hooks/useToggle"; -import { isProbablyTextContentType } from "../lib/contentType"; -import { getContentTypeFromHeaders } from "../lib/model_util"; -import { getResponseBodyText } from "../lib/responseBody"; -import { CopyButton } from "./CopyButton"; -import { Button } from "./core/Button"; -import { SizeTag } from "./core/SizeTag"; - -interface Props { - children: ReactNode; - response: HttpResponse; -} - -const LARGE_BYTES = 2 * 1000 * 1000; - -export function ConfirmLargeResponse({ children, response }: Props) { - const { mutate: saveResponse } = useSaveResponse(response); - const [showLargeResponse, toggleShowLargeResponse] = useToggle(); - const isProbablyText = useMemo(() => { - const contentType = getContentTypeFromHeaders(response.headers); - return isProbablyTextContentType(contentType); - }, [response.headers]); - - const contentLength = response.contentLength ?? 0; - const isLarge = contentLength > LARGE_BYTES; - if (!showLargeResponse && isLarge) { - return ( - -

- Showing responses over{" "} - - - {" "} - may impact performance -

- - - - {isProbablyText && ( - getResponseBodyText({ response, filter: null })} - /> - )} - -
- ); - } - - return <>{children}; -} diff --git a/apps/yaak-client/components/ConfirmLargeResponseRequest.tsx b/apps/yaak-client/components/ConfirmLargeResponseRequest.tsx deleted file mode 100644 index 92525f705..000000000 --- a/apps/yaak-client/components/ConfirmLargeResponseRequest.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import type { HttpResponse } from "@yaakapp-internal/models"; -import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui"; -import { type ReactNode, useMemo } from "react"; -import { getRequestBodyText as getHttpResponseRequestBodyText } from "../hooks/useHttpRequestBody"; -import { useToggle } from "../hooks/useToggle"; -import { isProbablyTextContentType } from "../lib/contentType"; -import { getContentTypeFromHeaders } from "../lib/model_util"; -import { CopyButton } from "./CopyButton"; -import { Button } from "./core/Button"; -import { SizeTag } from "./core/SizeTag"; - -interface Props { - children: ReactNode; - response: HttpResponse; -} - -const LARGE_BYTES = 2 * 1000 * 1000; - -export function ConfirmLargeResponseRequest({ children, response }: Props) { - const [showLargeResponse, toggleShowLargeResponse] = useToggle(); - const isProbablyText = useMemo(() => { - const contentType = getContentTypeFromHeaders(response.headers); - return isProbablyTextContentType(contentType); - }, [response.headers]); - - const contentLength = response.requestContentLength ?? 0; - const isLarge = contentLength > LARGE_BYTES; - if (!showLargeResponse && isLarge) { - return ( - -

- Showing content over{" "} - - - {" "} - may impact performance -

- - - {isProbablyText && ( - getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? "")} - /> - )} - -
- ); - } - - return <>{children}; -} diff --git a/apps/yaak-client/components/CookieDialog.tsx b/apps/yaak-client/components/CookieDialog.tsx index 31c90913d..0c7a831f4 100644 --- a/apps/yaak-client/components/CookieDialog.tsx +++ b/apps/yaak-client/components/CookieDialog.tsx @@ -25,12 +25,13 @@ import { TableHeaderCell, TableRow, TruncatedWideTableCell, + KeyValueRows, } from "@yaakapp-internal/ui"; import { IconButton } from "./core/IconButton"; import { Checkbox } from "./core/Checkbox"; import classNames from "classnames"; import { EventDetailHeader } from "./core/EventViewer"; -import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow"; +import { KeyValueRow } from "./core/KeyValueRow"; import { EmptyStateText } from "./EmptyStateText"; import { PlainInput } from "./core/PlainInput"; import { Select } from "./core/Select"; diff --git a/apps/yaak-client/components/CopyIconButton.tsx b/apps/yaak-client/components/CopyIconButton.tsx index a97a032cf..d55a4ec36 100644 --- a/apps/yaak-client/components/CopyIconButton.tsx +++ b/apps/yaak-client/components/CopyIconButton.tsx @@ -1,31 +1,22 @@ -import { IconButton, type IconButtonProps, useTimedBoolean } from "@yaakapp-internal/ui"; +import { + CopyIconButton as BaseCopyIconButton, + type CopyIconButtonProps, +} from "@yaakapp-internal/ui"; import { copyToClipboard } from "../lib/copy"; import { showToast } from "../lib/toast"; -interface Props extends Omit { - text: string | (() => Promise); +export function CopyIconButton(props: Omit) { + return ; } -export function CopyIconButton({ text, ...props }: Props) { - const [copied, setCopied] = useTimedBoolean(); - return ( - { - const content = typeof text === "function" ? await text() : text; - if (content == null) { - showToast({ - id: "failed-to-copy", - color: "danger", - message: "Failed to copy", - }); - } else { - copyToClipboard(content, { disableToast: true }); - setCopied(); - } - }} - /> - ); +function copy(text: string) { + copyToClipboard(text, { disableToast: true }); +} + +function showCopyFailed() { + showToast({ + id: "failed-to-copy", + color: "danger", + message: "Failed to copy", + }); } diff --git a/apps/yaak-client/components/CreateEnvironmentDialog.tsx b/apps/yaak-client/components/CreateEnvironmentDialog.tsx index 1436617a1..e5058133b 100644 --- a/apps/yaak-client/components/CreateEnvironmentDialog.tsx +++ b/apps/yaak-client/components/CreateEnvironmentDialog.tsx @@ -1,12 +1,12 @@ import { createWorkspaceModel } from "@yaakapp-internal/models"; -import { useState } from "react"; +import { useId, useState } from "react"; import { useToggle } from "../hooks/useToggle"; import { ColorIndicator } from "./ColorIndicator"; -import { Button } from "./core/Button"; import { Checkbox } from "./core/Checkbox"; import { ColorPickerWithThemeColors } from "./core/ColorPicker"; -import { Label } from "./core/Label"; +import { DialogFooter } from "./core/Dialog"; import { PlainInput } from "./core/PlainInput"; +import { Label } from "@yaakapp-internal/ui"; interface Props { onCreate: (id: string) => void; @@ -18,9 +18,11 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props) const [name, setName] = useState(""); const [color, setColor] = useState(null); const [sharable, toggleSharable] = useToggle(false); + const formId = useId(); return (
{ e.preventDefault(); const id = await createWorkspaceModel({ @@ -59,10 +61,17 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props) - + : undefined, + }, + ]} + /> ); } diff --git a/apps/yaak-client/components/CreateWorkspaceDialog.tsx b/apps/yaak-client/components/CreateWorkspaceDialog.tsx index 25e736127..557d089c1 100644 --- a/apps/yaak-client/components/CreateWorkspaceDialog.tsx +++ b/apps/yaak-client/components/CreateWorkspaceDialog.tsx @@ -1,15 +1,14 @@ import { gitMutations } from "@yaakapp-internal/git"; import type { WorkspaceMeta } from "@yaakapp-internal/models"; import { createGlobalModel, updateModel } from "@yaakapp-internal/models"; -import { VStack } from "@yaakapp-internal/ui"; -import { useState } from "react"; +import { VStack, Label } from "@yaakapp-internal/ui"; +import { useId, useState } from "react"; import { router } from "../lib/router"; import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption"; import { rpc } from "../lib/rpc"; import { showErrorToast } from "../lib/toast"; -import { Button } from "./core/Button"; import { Checkbox } from "./core/Checkbox"; -import { Label } from "./core/Label"; +import { DialogFooter } from "./core/Dialog"; import { PlainInput } from "./core/PlainInput"; import { EncryptionHelp } from "./EncryptionHelp"; import { gitCallbacks } from "./git/callbacks"; @@ -26,12 +25,13 @@ export function CreateWorkspaceDialog({ hide }: Props) { initGit?: boolean; }>({ filePath: null, initGit: false }); const [setupEncryption, setSetupEncryption] = useState(false); + const formId = useId(); return ( { e.preventDefault(); const workspaceId = await createGlobalModel({ model: "workspace", name }); @@ -89,9 +89,7 @@ export function CreateWorkspaceDialog({ hide }: Props) { title="Enable Encryption" /> - + ); } diff --git a/apps/yaak-client/components/DynamicForm.tsx b/apps/yaak-client/components/DynamicForm.tsx index adb314148..3f55cc754 100644 --- a/apps/yaak-client/components/DynamicForm.tsx +++ b/apps/yaak-client/components/DynamicForm.tsx @@ -11,7 +11,7 @@ import type { FormInputText, JsonPrimitive, } from "@yaakapp-internal/plugins"; -import { Banner, VStack } from "@yaakapp-internal/ui"; +import { Banner, VStack, Label } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo } from "react"; @@ -26,7 +26,6 @@ import { Editor } from "./core/Editor/LazyEditor"; import { IconButton } from "./core/IconButton"; import type { InputProps } from "./core/Input"; import { Input } from "./core/Input"; -import { Label } from "./core/Label"; import type { Pair } from "./core/PairEditor"; import { PairEditor } from "./core/PairEditor"; import { PlainInput } from "./core/PlainInput"; diff --git a/apps/yaak-client/components/EmptyStateText.tsx b/apps/yaak-client/components/EmptyStateText.tsx index 30f75bb40..4a557c4d2 100644 --- a/apps/yaak-client/components/EmptyStateText.tsx +++ b/apps/yaak-client/components/EmptyStateText.tsx @@ -1,24 +1 @@ -import classNames from "classnames"; -import type { ReactNode } from "react"; - -interface Props { - children: ReactNode; - className?: string; - wrapperClassName?: string; -} - -export function EmptyStateText({ children, className, wrapperClassName }: Props) { - return ( -
-
- {children} -
-
- ); -} +export { EmptyStateText } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/EnvironmentEditDialog.tsx b/apps/yaak-client/components/EnvironmentEditDialog.tsx index aecf1d877..8e53126ff 100644 --- a/apps/yaak-client/components/EnvironmentEditDialog.tsx +++ b/apps/yaak-client/components/EnvironmentEditDialog.tsx @@ -1,11 +1,19 @@ import type { Environment, Workspace } from "@yaakapp-internal/models"; import { duplicateModel, patchModel } from "@yaakapp-internal/models"; import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui"; -import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui"; +import { Banner, Icon, InlineCode, SplitLayout, Tree, IconTooltip } from "@yaakapp-internal/ui"; import { atom, useAtomValue } from "jotai"; import { atomFamily } from "jotai-family"; -import { useCallback, useLayoutEffect, useRef, useState } from "react"; -import { createSubEnvironmentAndActivate } from "../commands/createEnvironment"; +import { + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { createSubEnvironment } from "../commands/createEnvironment"; import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace"; import { environmentsBreakdownAtom, @@ -22,7 +30,6 @@ import { showColorPicker } from "../lib/showColorPicker"; import type { ContextMenuProps, DropdownItem } from "./core/Dropdown"; import { ContextMenu } from "./core/Dropdown"; import { IconButton } from "./core/IconButton"; -import { IconTooltip } from "./core/IconTooltip"; import type { PairEditorHandle } from "./core/PairEditor"; import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator"; import { EnvironmentEditor } from "./EnvironmentEditor"; @@ -40,6 +47,9 @@ interface Props { type TreeModel = Environment | Workspace; +/** Lets tree row slots create a sub-environment and select it in the editor. */ +const CreateSubEnvironmentContext = createContext<() => Promise>(async () => {}); + export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) { const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown(); const [selectedEnvironmentId, setSelectedEnvironmentId] = useState( @@ -115,7 +125,7 @@ function EnvironmentEditDialogSidebar({ const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? ""; const treeId = `environment.${activeWorkspaceId}.sidebar`; const treeRef = useRef(null); - const { baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown(); + const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown(); // oxlint-disable-next-line react-hooks/exhaustive-deps -- none useLayoutEffect(() => { @@ -124,6 +134,31 @@ function EnvironmentEditDialogSidebar({ treeRef.current?.focus(); }, []); + // A just-created environment to select once it reaches the model store. Selecting it before + // then would show a "failed to find" error in the editor, and the tree would drop the + // selection of an item it doesn't have yet. + const [pendingSelectionId, setPendingSelectionId] = useState(null); + const tree = useAtomValue(treeAtom); + useEffect(() => { + if (pendingSelectionId == null) return; + if (!allEnvironments.some((e) => e.id === pendingSelectionId)) return; + + setPendingSelectionId(null); + setSelectedEnvironmentId(pendingSelectionId); + + // The tree leaves out sub-environments while there are multiple base environments + const node = tree == null ? null : findTreeNode(tree, pendingSelectionId); + if (node == null) return; + if (node.parent != null) { + // Expand the parent so the new item is visible to be selected + const parentId = node.parent.item.id; + jotaiStore.set(collapsedFamily(treeId), (prev) => ({ ...prev, [parentId]: false })); + } + // Wait a frame so the tree's own pending selection fixups, which may have been scheduled + // before the new item arrived, don't reset this selection + requestAnimationFrame(() => treeRef.current?.selectItem(pendingSelectionId)); + }, [allEnvironments, pendingSelectionId, setSelectedEnvironmentId, tree, treeId]); + const handleDeleteEnvironment = useCallback( async (environment: Environment) => { await deleteModelWithConfirm(environment); @@ -134,6 +169,14 @@ function EnvironmentEditDialogSidebar({ [baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId], ); + const handleCreateSubEnvironment = useCallback(async () => { + const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom); + if (baseEnvironment == null) return; + const id = await createSubEnvironment.mutateAsync(baseEnvironment); + // Show the new environment in the editor without changing the workspace's active environment + if (id != null) setPendingSelectionId(id); + }, []); + const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []); const getSelectedTreeModels = useCallback( @@ -195,13 +238,14 @@ function EnvironmentEditDialogSidebar({ const addEnvironmentItem: DropdownItem = { label: "Create Sub Environment", leftSlot: , - onSelect: async () => { - await createSubEnvironment(); - }, + onSelect: handleCreateSubEnvironment, }; + // Sub-environments aren't shown while there are multiple base environments, same as the + // plus button on the base environment row + const canCreateSubEnvironment = baseEnvironments.length <= 1; if (environment == null || environment.model !== "environment") { - return [addEnvironmentItem]; + return canCreateSubEnvironment ? [addEnvironmentItem] : []; } const singleEnvironment = items.length === 1; @@ -257,7 +301,7 @@ function EnvironmentEditDialogSidebar({ ]; // Add sub environment to base environment - if (isBaseEnvironment(environment) && singleEnvironment) { + if (isBaseEnvironment(environment) && singleEnvironment && canCreateSubEnvironment) { menuItems.push({ type: "separator" }); menuItems.push(addEnvironmentItem); } @@ -266,6 +310,7 @@ function EnvironmentEditDialogSidebar({ }, [ baseEnvironments.length, + handleCreateSubEnvironment, handleDeleteEnvironment, handleDuplicateSelected, handleRenameSelected, @@ -317,34 +362,37 @@ function EnvironmentEditDialogSidebar({ ); const renderContextMenuFn = useCallback["renderContextMenu"]>>( - ({ items, position, onClose }) => ( - - ), + ({ items, position, onClose }) => + // Right-clicking empty space has nothing to offer while there are multiple base environments + items.length === 0 ? null : ( + + ), [], ); - const tree = useAtomValue(treeAtom); return ( ); @@ -384,6 +432,15 @@ const treeAtom = atom | null>((get) => { return root; }); +function findTreeNode(node: TreeNode, id: string): TreeNode | null { + if (node.item.id === id) return node; + for (const child of node.children ?? []) { + const found = findTreeNode(child, id); + if (found != null) return found; + } + return null; +} + function ItemLeftSlotInner({ item }: { item: TreeModel }) { const { baseEnvironments } = useEnvironmentsBreakdown(); return baseEnvironments.length > 1 ? ( @@ -395,6 +452,7 @@ function ItemLeftSlotInner({ item }: { item: TreeModel }) { function ItemRightSlot({ item }: { item: TreeModel }) { const { baseEnvironments } = useEnvironmentsBreakdown(); + const handleCreateSubEnvironment = useContext(CreateSubEnvironmentContext); return ( <> {item.model === "environment" && baseEnvironments.length <= 1 && isBaseEnvironment(item) && ( @@ -405,7 +463,7 @@ function ItemRightSlot({ item }: { item: TreeModel }) { icon="plus_circle" className="opacity-50 hover:opacity-100" title="Add Sub-Environment" - onClick={createSubEnvironment} + onClick={handleCreateSubEnvironment} /> )} @@ -425,13 +483,6 @@ function ItemInner({ item }: { item: TreeModel }) { ); } -async function createSubEnvironment() { - const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom); - if (baseEnvironment == null) return; - const id = await createSubEnvironmentAndActivate.mutateAsync(baseEnvironment); - return id; -} - function getEditOptions(item: TreeModel) { const options: ReturnType["getEditOptions"]>> = { defaultValue: item.name, diff --git a/apps/yaak-client/components/EnvironmentSharableTooltip.tsx b/apps/yaak-client/components/EnvironmentSharableTooltip.tsx index 642d76eb2..aa8ef57c9 100644 --- a/apps/yaak-client/components/EnvironmentSharableTooltip.tsx +++ b/apps/yaak-client/components/EnvironmentSharableTooltip.tsx @@ -1,4 +1,4 @@ -import { IconTooltip } from "./core/IconTooltip"; +import { IconTooltip } from "@yaakapp-internal/ui"; export function EnvironmentSharableTooltip() { return ( diff --git a/apps/yaak-client/components/ErrorBoundary.tsx b/apps/yaak-client/components/ErrorBoundary.tsx index a764bfe77..dfc4f85d3 100644 --- a/apps/yaak-client/components/ErrorBoundary.tsx +++ b/apps/yaak-client/components/ErrorBoundary.tsx @@ -1,60 +1,19 @@ -import { Banner, Button, InlineCode } from "@yaakapp-internal/ui"; -import type { ErrorInfo, ReactNode } from "react"; -import { Component, useEffect } from "react"; +import { ErrorBoundary as BaseErrorBoundary } from "@yaakapp-internal/ui"; +import type { ReactNode } from "react"; +import { useEffect } from "react"; import { showDialog } from "../lib/dialog"; import RouteError from "./RouteError"; -interface ErrorBoundaryProps { - name: string; - children: ReactNode; +export function ErrorBoundary({ name, children }: { name: string; children: ReactNode }) { + return ( + + {children} + + ); } -interface ErrorBoundaryState { - hasError: boolean; - error: Error | null; -} - -export class ErrorBoundary extends Component { - constructor(props: ErrorBoundaryProps) { - super(props); - this.state = { hasError: false, error: null }; - } - - static getDerivedStateFromError(error: Error): ErrorBoundaryState { - return { hasError: true, error }; - } - - componentDidCatch(error: Error, info: ErrorInfo) { - console.warn("Error caught by ErrorBoundary:", error, info); - } - - render() { - if (this.state.hasError) { - return ( - -
- Error rendering {this.props.name} component -
- -
- ); - } - - return this.props.children; - } +export function showErrorDialog(error: unknown) { + showDialog({ id: "error-boundary", render: () => }); } export function ErrorBoundaryTestThrow() { diff --git a/apps/yaak-client/components/ExportDataDialog.tsx b/apps/yaak-client/components/ExportDataDialog.tsx index a43e340f1..1f27c7124 100644 --- a/apps/yaak-client/components/ExportDataDialog.tsx +++ b/apps/yaak-client/components/ExportDataDialog.tsx @@ -1,18 +1,18 @@ import type { Workspace } from "@yaakapp-internal/models"; import { workspacesAtom } from "@yaakapp-internal/models"; -import { HStack, VStack } from "@yaakapp-internal/ui"; +import { VStack } from "@yaakapp-internal/ui"; import { useAtomValue } from "jotai"; import { useCallback, useMemo, useState } from "react"; import slugify from "slugify"; import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace"; -import { pluralizeCount } from "../lib/pluralize"; import { rpc } from "../lib/rpc"; import { CommercialUseBanner } from "./CommercialUseBanner"; -import { Button } from "./core/Button"; import { Checkbox } from "./core/Checkbox"; import { DetailsBanner } from "./core/DetailsBanner"; +import { DialogFooter } from "./core/Dialog"; import { Link } from "./core/Link"; import { platform } from "@yaakapp-internal/platform"; +import { pluralizeCount } from "@yaakapp-internal/lib/pluralize"; interface Props { onHide: () => void; @@ -65,29 +65,30 @@ function ExportDataDialogContent({ const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]); const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined; const slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces"; - const exportPath = await platform.dialog.save({ - title: "Export Data", - defaultPath: `yaak.${slug}.json`, - }); - if (exportPath == null) { - return; - } - - await rpc("cmd_export_data", { + const document = await rpc("cmd_export_data", { workspaceIds: ids, - exportPath, includePrivateEnvironments: includePrivateEnvironments, }); + + const savedTo = await platform.files.save( + `yaak.${slug}.json`, + new TextEncoder().encode(document), + [{ name: "JSON", extensions: ["json"] }], + ); + if (savedTo == null) { + return; // Cancelled + } + onHide(); - onSuccess(exportPath); + onSuccess(savedTo); }, [includePrivateEnvironments, onHide, onSuccess, selectedWorkspaces, workspaces]); const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]); const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length; const noneSelected = numSelected === 0; return ( -
- + <> + @@ -140,29 +141,22 @@ function ExportDataDialogContent({ /> -
-
+ Create Run Button -
- - - - -
- + } + actions={[ + { label: "Cancel", onClick: onHide }, + { + label: `Export ${pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}`, + color: "primary", + disabled: noneSelected, + onClick: () => handleExport(), + }, + ]} + /> + ); } diff --git a/apps/yaak-client/components/FolderLayout.tsx b/apps/yaak-client/components/FolderLayout.tsx index a693080a5..14c3085db 100644 --- a/apps/yaak-client/components/FolderLayout.tsx +++ b/apps/yaak-client/components/FolderLayout.tsx @@ -1,6 +1,14 @@ import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models"; import { foldersAtom } from "@yaakapp-internal/models"; -import { Heading, HStack, Icon, LoadingIcon } from "@yaakapp-internal/ui"; +import { + Heading, + HStack, + Icon, + LoadingIcon, + HttpResponseDurationTag, + HttpStatusTag, + SizeTag, +} from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtomValue } from "jotai"; import type { CSSProperties, ReactNode } from "react"; @@ -13,11 +21,8 @@ import { showDialog } from "../lib/dialog"; import { resolvedModelName } from "../lib/resolvedModelName"; import { router } from "../lib/router"; import { Button } from "./core/Button"; -import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag"; -import { HttpStatusTag } from "./core/HttpStatusTag"; import { IconButton } from "./core/IconButton"; import { Separator } from "./core/Separator"; -import { SizeTag } from "./core/SizeTag"; import { HttpResponsePane } from "./HttpResponsePane"; interface Props { diff --git a/apps/yaak-client/components/FolderSettingsDialog.tsx b/apps/yaak-client/components/FolderSettingsDialog.tsx index 7b0a3e4e9..66758a153 100644 --- a/apps/yaak-client/components/FolderSettingsDialog.tsx +++ b/apps/yaak-client/components/FolderSettingsDialog.tsx @@ -1,5 +1,5 @@ import { createWorkspaceModel, foldersAtom, patchModel } from "@yaakapp-internal/models"; -import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui"; +import { HStack, Icon, InlineCode, VStack, CountBadge } from "@yaakapp-internal/ui"; import { useAtomValue } from "jotai"; import { Fragment, useMemo } from "react"; import { useAuthTab } from "../hooks/useAuthTab"; @@ -11,7 +11,6 @@ import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm"; import { hideDialog } from "../lib/dialog"; import { CopyIconButton } from "./CopyIconButton"; import { Button } from "./core/Button"; -import { CountBadge } from "./core/CountBadge"; import { Input } from "./core/Input"; import { Link } from "./core/Link"; import type { TabItem } from "./core/Tabs/Tabs"; diff --git a/apps/yaak-client/components/FormUrlencodedEditor.tsx b/apps/yaak-client/components/FormUrlencodedEditor.tsx deleted file mode 100644 index 94a0aa03b..000000000 --- a/apps/yaak-client/components/FormUrlencodedEditor.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import type { HttpRequest } from "@yaakapp-internal/models"; -import { useCallback, useMemo } from "react"; -import type { Pair, PairEditorProps } from "./core/PairEditor"; -import { PairOrBulkEditor } from "./core/PairOrBulkEditor"; - -type Props = { - forceUpdateKey: string; - request: HttpRequest; - onChange: (headers: HttpRequest["body"]) => void; -}; - -export function FormUrlencodedEditor({ request, forceUpdateKey, onChange }: Props) { - const pairs = useMemo( - () => - (Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({ - enabled: !!p.enabled, - name: p.name || "", - value: p.value || "", - id: p.id, - })), - [request.body.form], - ); - - const handleChange = useCallback( - (pairs) => - onChange({ form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, value: p.value })) }), - [onChange], - ); - - return ( - - ); -} diff --git a/apps/yaak-client/components/GlobalHooks.tsx b/apps/yaak-client/components/GlobalHooks.tsx index 67499e94a..240ab9848 100644 --- a/apps/yaak-client/components/GlobalHooks.tsx +++ b/apps/yaak-client/components/GlobalHooks.tsx @@ -1,3 +1,4 @@ +import type { NavigateWorkspace } from "@yaakapp-internal/tauri-client"; import { activeRequestAtom } from "../hooks/useActiveRequest"; import { useSubscribeActiveWorkspaceId } from "../hooks/useActiveWorkspace"; import { useActiveWorkspaceChangedToast } from "../hooks/useActiveWorkspaceChangedToast"; @@ -5,10 +6,13 @@ import { useHotKey, useSubscribeHotKeys } from "../hooks/useHotKey"; import { useSubscribeHttpAuthentication } from "../hooks/useHttpAuthentication"; import { useSyncFontSizeSetting } from "../hooks/useSyncFontSizeSetting"; import { useSyncWorkspaceChildModels } from "../hooks/useSyncWorkspaceChildModels"; +import { usePlatformEvent } from "../hooks/usePlatformEvent"; import { useSyncZoomSetting } from "../hooks/useSyncZoomSetting"; import { useSubscribeTemplateFunctions } from "../hooks/useTemplateFunctions"; +import { fireAndForget } from "../lib/fireAndForget"; import { jotaiStore } from "../lib/jotai"; import { renameModelWithPrompt } from "../lib/renameModelWithPrompt"; +import { router } from "../lib/router"; export function GlobalHooks() { useSyncZoomSetting(); @@ -24,6 +28,20 @@ export function GlobalHooks() { useActiveWorkspaceChangedToast(); useSubscribeHotKeys(); + usePlatformEvent("show_home", () => { + fireAndForget(router.navigate({ to: "/", search: { home: true } })); + }); + + usePlatformEvent("navigate_workspace", ({ workspaceId, environmentId }) => { + fireAndForget( + router.navigate({ + to: "/workspaces/$workspaceId", + params: { workspaceId }, + search: { environment_id: environmentId }, + }), + ); + }); + useHotKey( "request.rename", async () => { diff --git a/apps/yaak-client/components/GrpcEditor.tsx b/apps/yaak-client/components/GrpcEditor.tsx index e44fc1f22..c626f46e0 100644 --- a/apps/yaak-client/components/GrpcEditor.tsx +++ b/apps/yaak-client/components/GrpcEditor.tsx @@ -20,13 +20,13 @@ 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"; +import { pluralizeCount } from "@yaakapp-internal/lib/pluralize"; type Props = Pick & { services: ReflectResponseService[] | null; @@ -239,6 +239,7 @@ export function GrpcEditor({ showDialog({ title: "Configure Schema", size: "md", + className: "h-[30rem]", id: "grpc-configure-schema", render: ({ hide }) => , }); diff --git a/apps/yaak-client/components/GrpcProtoSelectionDialog.tsx b/apps/yaak-client/components/GrpcProtoSelectionDialog.tsx index cafa422ed..e010deb87 100644 --- a/apps/yaak-client/components/GrpcProtoSelectionDialog.tsx +++ b/apps/yaak-client/components/GrpcProtoSelectionDialog.tsx @@ -1,13 +1,13 @@ import type { GrpcRequest } from "@yaakapp-internal/models"; -import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui"; +import { Banner, InlineCode, VStack } from "@yaakapp-internal/ui"; import { useActiveRequest } from "../hooks/useActiveRequest"; import { useGrpc } from "../hooks/useGrpc"; import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles"; -import { pluralizeCount } from "../lib/pluralize"; -import { Button } from "./core/Button"; -import { IconButton } from "./core/IconButton"; +import { DialogFooter } from "./core/Dialog"; +import { PathList } from "./core/PathList"; import { Link } from "./core/Link"; import { platform } from "@yaakapp-internal/platform"; +import { pluralizeCount } from "@yaakapp-internal/lib/pluralize"; interface Props { onDone: () => void; @@ -20,7 +20,10 @@ export function GrpcProtoSelectionDialog(props: Props) { return ; } -function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: GrpcRequest }) { +function GrpcProtoSelectionDialogWithRequest({ + request, + onDone, +}: Props & { request: GrpcRequest }) { const protoFilesKv = useGrpcProtoFiles(request.id); const protoFiles = protoFilesKv.value ?? []; const grpc = useGrpc(request, null, protoFiles); @@ -38,54 +41,8 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp } return ( - - {/* Buttons on top so they get focus first */} - - - - - - + <> + {reflectError && (

@@ -128,46 +85,47 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp )} - {protoFiles.length > 0 && ( -

- - - - - - - {protoFiles.map((f, i) => { - const parts = f.split("/"); - // oxlint-disable-next-line no-array-index-key -- none - return ( - - - - - - ); - })} - -
- Added File Paths -
- - - {parts.length > 3 && ".../"} - {parts.slice(-3).join("/")} - - { - await protoFilesKv.set(protoFiles.filter((p) => p !== f)); - }} - /> -
- )} + ({ + path, + icon: path.endsWith(".proto") ? "file_code" : "folder_code", + }))} + onRemove={async (path) => { + await protoFilesKv.set(protoFiles.filter((p) => p !== path)); + await grpc.reflect.refetch(); + }} + addLabel="Add proto files or an import folder" + addItems={[ + { + label: "Proto files", + icon: "file_code", + onSelect: async () => { + const selected = await platform.dialog.open({ + title: "Select Proto Files", + multiple: true, + filters: [{ name: "Proto Files", extensions: ["proto"] }], + }); + if (selected == null) return; + const newFiles = selected.filter((p) => !protoFiles.includes(p)); + await protoFilesKv.set([...protoFiles, ...newFiles]); + await grpc.reflect.refetch(); + }, + }, + { + label: "Import folder", + icon: "folder_code", + onSelect: async () => { + const selected = await platform.dialog.open({ + title: "Select Proto Directory", + directory: true, + }); + if (selected == null) return; + await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]); + await grpc.reflect.refetch(); + }, + }, + ]} + /> {reflectionUnimplemented && protoFiles.length === 0 && ( {request.url} doesn't implement{" "} @@ -178,6 +136,17 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp )}
-
+ grpc.reflect.refetch(), + }, + { label: "Done", color: "primary", onClick: onDone }, + ]} + /> + ); } diff --git a/apps/yaak-client/components/GrpcRequestPane.tsx b/apps/yaak-client/components/GrpcRequestPane.tsx index bec53ee4a..43754b864 100644 --- a/apps/yaak-client/components/GrpcRequestPane.tsx +++ b/apps/yaak-client/components/GrpcRequestPane.tsx @@ -4,7 +4,7 @@ import { patchModel, patchModelDebounced, } from "@yaakapp-internal/models"; -import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui"; +import { HStack, Icon, useContainerSize, VStack, CountBadge } from "@yaakapp-internal/ui"; import classNames from "classnames"; import type { CSSProperties } from "react"; import { useCallback, useMemo, useRef } from "react"; @@ -15,7 +15,6 @@ import { useInheritedHeaders } from "../hooks/useInheritedHeaders"; import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey"; import { resolvedModelName } from "../lib/resolvedModelName"; import { Button } from "./core/Button"; -import { CountBadge } from "./core/CountBadge"; import { IconButton } from "./core/IconButton"; import { PlainInput } from "./core/PlainInput"; import { RadioDropdown } from "./core/RadioDropdown"; @@ -133,6 +132,8 @@ export function GrpcRequestPane({ onSend({ message: activeRequest.message }); }, [activeRequest, onSend]); + const hasDescription = activeRequest.description.trim().length > 0; + const tabs: TabItem[] = useMemo( () => [ { value: TAB_MESSAGE, label: "Message" }, @@ -146,10 +147,10 @@ export function GrpcRequestPane({ { value: TAB_DESCRIPTION, label: "Info", - rightSlot: activeRequest.description && , + rightSlot: hasDescription && , }, ], - [activeRequest.description, authTab, metadataTab, numSettingsOverrides], + [authTab, hasDescription, metadataTab, numSettingsOverrides], ); const handleMetadataChange = useCallback( diff --git a/apps/yaak-client/components/GrpcResponsePane.tsx b/apps/yaak-client/components/GrpcResponsePane.tsx index 601b3e12b..7228011f0 100644 --- a/apps/yaak-client/components/GrpcResponsePane.tsx +++ b/apps/yaak-client/components/GrpcResponsePane.tsx @@ -1,5 +1,13 @@ import type { GrpcEvent, GrpcRequest } from "@yaakapp-internal/models"; -import { HStack, Icon, type IconProps, LoadingIcon, VStack } from "@yaakapp-internal/ui"; +import { + HStack, + Icon, + type IconProps, + LoadingIcon, + VStack, + useStateWithDeps, + KeyValueRows, +} from "@yaakapp-internal/ui"; import { useAtomValue, useSetAtom } from "jotai"; import type { CSSProperties } from "react"; import { useEffect, useMemo, useState } from "react"; @@ -9,13 +17,12 @@ import { pinnedGrpcConnectionIdAtom, useGrpcEvents, } from "../hooks/usePinnedGrpcConnection"; -import { useStateWithDeps } from "../hooks/useStateWithDeps"; import { Button } from "./core/Button"; import { Editor } from "./core/Editor/LazyEditor"; import { EventDetailHeader, EventViewer } from "./core/EventViewer"; import { EventViewerRow } from "./core/EventViewerRow"; import { HotkeyList } from "./core/HotkeyList"; -import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow"; +import { KeyValueRow } from "./core/KeyValueRow"; import { EmptyStateText } from "./EmptyStateText"; import { ErrorBoundary } from "./ErrorBoundary"; import { RecentGrpcConnectionsDropdown } from "./RecentGrpcConnectionsDropdown"; diff --git a/apps/yaak-client/components/HeadersEditor.tsx b/apps/yaak-client/components/HeadersEditor.tsx index fa1d80105..77c9cf9d3 100644 --- a/apps/yaak-client/components/HeadersEditor.tsx +++ b/apps/yaak-client/components/HeadersEditor.tsx @@ -1,190 +1 @@ -import type { HttpRequestHeader } from "@yaakapp-internal/models"; -import { HStack } from "@yaakapp-internal/ui"; -import { acceptLanguages } from "../lib/data/acceptLanguages"; -import { cacheControlDirectives } from "../lib/data/cacheControl"; -import { charsets } from "../lib/data/charsets"; -import { connections } from "../lib/data/connections"; -import { encodings } from "../lib/data/encodings"; -import type { HeaderValuePreset } from "../lib/data/headerValuePresets"; -import { headerNames } from "../lib/data/headerNames"; -import { mimeTypes } from "../lib/data/mimetypes"; -import { userAgents } from "../lib/data/userAgents"; -import { CountBadge } from "./core/CountBadge"; -import { DetailsBanner } from "./core/DetailsBanner"; -import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion"; -import type { InputProps } from "./core/Input"; -import type { Pair, PairEditorProps } from "./core/PairEditor"; -import { PairEditorRow } from "./core/PairEditor"; -import { ensurePairId } from "./core/PairEditor.util"; -import { PairOrBulkEditor } from "./core/PairOrBulkEditor"; - -type Props = { - forceUpdateKey: string; - headers: HttpRequestHeader[]; - inheritedHeaders?: HttpRequestHeader[]; - inheritedHeadersLabel?: string; - stateKey: string; - onChange: (headers: HttpRequestHeader[]) => void; - label?: string; -}; - -export function HeadersEditor({ - stateKey, - headers, - inheritedHeaders, - inheritedHeadersLabel = "Inherited", - onChange, - forceUpdateKey, -}: Props) { - // Get header names defined at current level (case-insensitive) - const currentHeaderNames = new Set( - headers.filter((h) => h.name).map((h) => h.name.toLowerCase()), - ); - // Filter inherited headers: must be enabled, have content, and not be overridden by current level - const validInheritedHeaders = - inheritedHeaders?.filter( - (pair) => - pair.enabled && - (pair.name || pair.value) && - !currentHeaderNames.has(pair.name.toLowerCase()), - ) ?? []; - const hasInheritedHeaders = validInheritedHeaders.length > 0; - return ( -
- {hasInheritedHeaders && ( - - {inheritedHeadersLabel} - - } - > -
- {validInheritedHeaders?.map((pair, i) => ( - - ))} -
-
- )} - -
- ); -} - -const MIN_MATCH = 3; - -const headerOptionsMap: Record = { - "content-type": mimeTypes, - accept: ["*/*", ...mimeTypes], - "accept-encoding": encodings, - "content-encoding": encodings, - connection: connections, - "accept-charset": charsets, - "accept-language": acceptLanguages, - "cache-control": cacheControlDirectives, - "user-agent": userAgents, - pragma: ["no-cache"], - te: ["trailers", "compress", "deflate", "gzip"], - dnt: ["1", "0"], - "upgrade-insecure-requests": ["1"], - "x-requested-with": ["XMLHttpRequest"], -}; - -const valueType = (pair: Pair): InputProps["type"] => { - const name = pair.name.toLowerCase().trim(); - if ( - name.includes("authorization") || - name.includes("api-key") || - name.includes("access-token") || - name.includes("auth") || - name.includes("secret") || - name.includes("token") || - name === "cookie" || - name === "set-cookie" - ) { - return "password"; - } - return "text"; -}; - -const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => { - const name = headerName.toLowerCase().trim(); - const options: GenericCompletion[] = - headerOptionsMap[name]?.map((o) => - typeof o === "string" - ? { - label: o, - type: "constant", - boost: 1, // Put above other completions - } - : { - // Show the short label but insert the full value (e.g. User-Agent) - label: o.label, - apply: o.value, - type: "constant", - boost: 1, - }, - ) ?? []; - return { minMatch: MIN_MATCH, options }; -}; - -const nameAutocomplete: PairEditorProps["nameAutocomplete"] = { - minMatch: MIN_MATCH, - options: headerNames.map((t) => - typeof t === "string" - ? { - label: t, - type: "constant", - boost: 1, // Put above other completions - } - : { - ...t, - boost: 1, // Put above other completions - }, - ), -}; - -const validateHttpHeader = (v: string) => { - if (v === "") { - return true; - } - - // Template strings are not allowed so we replace them with a valid example string - const withoutTemplateStrings = v.replace(/\$\{\[\s*[^\]\s]+\s*]}/gi, "123"); - return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null; -}; +export { HeadersEditor } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/HttpRequestPane.tsx b/apps/yaak-client/components/HttpRequestPane.tsx index c6d25c3f4..579c26622 100644 --- a/apps/yaak-client/components/HttpRequestPane.tsx +++ b/apps/yaak-client/components/HttpRequestPane.tsx @@ -1,9 +1,16 @@ import type { HttpRequest } from "@yaakapp-internal/models"; import { getModel, patchModel, patchModelDebounced } from "@yaakapp-internal/models"; import type { GenericCompletionOption } from "@yaakapp-internal/plugins"; -import classNames from "classnames"; +import { + CountBadge, + type EditablePair, + type ExtraPaneTab, + HttpRequestPaneView, + InlineCode, + languageFromContentType, +} from "@yaakapp-internal/ui"; import { atom, useAtomValue } from "jotai"; -import type { CSSProperties } from "react"; +import type { CSSProperties, ReactNode } from "react"; import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react"; import { importCurl, looksLikeCurl } from "../commands/importCurl"; import { allRequestUrlsAtom } from "../hooks/useAllRequests"; @@ -15,10 +22,8 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse"; import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor"; import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey"; import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest"; -import { languageFromContentType } from "../lib/contentType"; -import { generateId } from "../lib/generateId"; -import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders"; -import { convertRequestBody } from "../lib/requestBodyConversion"; +import { fireAndForget } from "../lib/fireAndForget"; +import { httpRequestToView } from "../lib/httpViews"; import { BODY_TYPE_BINARY, BODY_TYPE_FORM_MULTIPART, @@ -30,30 +35,24 @@ import { BODY_TYPE_XML, getContentTypeFromHeaders, } from "../lib/model_util"; +import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders"; import { prepareImportQuerystring } from "../lib/prepareImportQuerystring"; +import { convertRequestBody } from "../lib/requestBodyConversion"; import { resolvedModelName } from "../lib/resolvedModelName"; import { showToast } from "../lib/toast"; import { BinaryFileEditor } from "./BinaryFileEditor"; import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody"; -import { CountBadge } from "./core/CountBadge"; import type { GenericCompletionConfig } from "./core/Editor/genericCompletion"; import { getUrlCompletionConfig } from "./core/Editor/url/completion"; -import { Editor } from "./core/Editor/LazyEditor"; -import { InlineCode } from "@yaakapp-internal/ui"; import { PlainInput } from "./core/PlainInput"; import type { TabItem, TabsRef } from "./core/Tabs/Tabs"; -import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs"; -import { EmptyStateText } from "./EmptyStateText"; -import { FormMultipartEditor } from "./FormMultipartEditor"; -import { FormUrlencodedEditor } from "./FormUrlencodedEditor"; -import { HeadersEditor } from "./HeadersEditor"; +import { setActiveTab } from "./core/Tabs/Tabs"; import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor"; -import { JsonBodyEditor } from "./JsonBodyEditor"; import { MarkdownEditor } from "./MarkdownEditor"; -import { RequestMethodDropdown } from "./RequestMethodDropdown"; import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor"; -import { UrlBar } from "./UrlBar"; +import { RequestMethodDropdown } from "./RequestMethodDropdown"; import { UrlParametersEditor } from "./UrlParameterEditor"; +import { generateId } from "@yaakapp-internal/lib/generateId"; const GraphQLEditor = lazy(() => import("./graphql/GraphQLEditor").then((m) => ({ default: m.GraphQLEditor })), @@ -165,9 +164,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: : 0; } - const tabs = useMemo( - () => [ - { + const hasDescription = activeRequest.description.trim().length > 0; + + const bodyTab = useMemo( + () => + ({ value: TAB_BODY, rightSlot: numParams > 0 ? : null, options: { @@ -244,34 +245,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: } }, }, - }, - { - value: TAB_PARAMS, - rightSlot: , - label: "Params", - }, - ...headersTab, - ...authTab, - { - value: TAB_SETTINGS, - label: "Settings", - rightSlot: , - }, - { - value: TAB_DESCRIPTION, - label: "Info", - }, - ], - [ - activeRequest, - authTab, - contentType, - handleContentTypeChange, - headersTab, - numParams, - numSettingsOverrides, - urlParameterPairs.length, - ], + }) satisfies TabItem, + [activeRequest, contentType, handleContentTypeChange, numParams], ); const { mutate: sendRequest } = useSendAnyHttpRequest(); @@ -333,154 +308,152 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: [activeRequest], ); - return ( -
- {activeRequest && ( - <> - - -
- } - forceUpdateKey={updateKey} - isLoading={activeResponse != null && activeResponse.state !== "closed"} + const handleJsonOrBodyTextChange = useCallback( + (text: string, bodyType: string | null) => { + // JSON writes at once, as its editor always has; other text debounces + if (bodyType === BODY_TYPE_JSON) { + return patchModel(activeRequest, { body: { ...activeRequest.body, text } }); + } + return handleBodyTextChange(text); + }, + [activeRequest, handleBodyTextChange], + ); + + const handleSendJsonCommentsChange = useCallback( + (sendJsonComments: boolean) => { + const newBody = { ...activeRequest.body }; + if (sendJsonComments) { + newBody.sendJsonComments = true; + } else { + delete newBody.sendJsonComments; + } + fireAndForget(patchModel(activeRequest, { body: newBody })); + }, + [activeRequest], + ); + + const requestView = useMemo(() => httpRequestToView(activeRequest), [activeRequest]); + + const extraTabs = useMemo( + () => [ + { + tab: { + value: TAB_SETTINGS, + label: "Settings", + rightSlot: , + }, + content: , + }, + { + tab: { + value: TAB_DESCRIPTION, + label: "Info", + rightSlot: hasDescription && , + }, + content: ( +
+ patchModel(activeRequest, { name })} + /> + patchModel(activeRequest, { description })} + /> +
+ ), + }, + ], + [activeRequest, hasDescription, numSettingsOverrides, updateKey], + ); + + const renderBody = useCallback( + (sharedEditor: ReactNode) => ( + + {activeRequest.bodyType === BODY_TYPE_GRAPHQL ? ( + + + + ) : activeRequest.bodyType === BODY_TYPE_BINARY ? ( + patchModelDebounced(activeRequest, { body })} + onChangeContentType={handleContentTypeChange} /> - - - - - - patchModelDebounced(activeRequest, { headers })} - /> - - - patchModelDebounced(activeRequest, { urlParameters })} - /> - - - - - - - {activeRequest.bodyType === BODY_TYPE_JSON ? ( - - ) : activeRequest.bodyType === BODY_TYPE_XML ? ( - - ) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? ( - - - - ) : activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ? ( - - ) : activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART ? ( - - ) : activeRequest.bodyType === BODY_TYPE_BINARY ? ( - patchModelDebounced(activeRequest, { body })} - onChangeContentType={handleContentTypeChange} - /> - ) : typeof activeRequest.bodyType === "string" ? ( - - ) : ( - No Body - )} - - - -
- patchModel(activeRequest, { name })} - /> - patchModel(activeRequest, { description })} - /> -
-
-
- - )} -
+ ) : ( + sharedEditor + )} + + ), + [activeRequest, contentType, forceUpdateKey, handleBodyChange, handleContentTypeChange], + ); + + const renderUrlParameters = useCallback( + (pairs: EditablePair[]) => ( + patchModelDebounced(activeRequest, { urlParameters })} + /> + ), + [activeRequest, forceUpdateKey, urlParametersKey], + ); + + return ( + } + bodyTab={bodyTab} + renderBody={renderBody} + onBodyChange={handleBodyChange} + onBodyTextChange={handleJsonOrBodyTextChange} + onSendJsonCommentsChange={handleSendJsonCommentsChange} + jsonNoticeStorageKey={activeRequest.workspaceId} + urlParameterPairs={urlParameterPairs} + renderUrlParameters={renderUrlParameters} + headersTabs={headersTab} + inheritedHeaders={inheritedHeaders} + headersForceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`} + onHeadersChange={(headers) => patchModelDebounced(activeRequest, { headers })} + authTabs={authTab} + authContent={} + extraTabs={extraTabs} + /> ); } diff --git a/apps/yaak-client/components/HttpResponsePane.tsx b/apps/yaak-client/components/HttpResponsePane.tsx index e133ed78f..2016f334e 100644 --- a/apps/yaak-client/components/HttpResponsePane.tsx +++ b/apps/yaak-client/components/HttpResponsePane.tsx @@ -1,51 +1,38 @@ import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models"; -import { Banner, HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui"; -import classNames from "classnames"; -import type { ComponentType, CSSProperties } from "react"; -import { lazy, Suspense, useMemo } from "react"; +import { + CountBadge, + type ExtraPaneTab, + type HostBodyViewerArgs, + HStack, + HttpResponsePaneView, + Icon, + type ResponseTextViewerExtras, + type TextFilterApi, + Tooltip, + VStack, +} from "@yaakapp-internal/ui"; +import type { CSSProperties } from "react"; +import { useCallback, useMemo } from "react"; import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse"; import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse"; +import { useRequestBodyLoader, useResponseBodyLoader } from "../hooks/useHttpBodyLoaders"; import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents"; import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse"; -import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText"; -import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl"; +import { useRecentFilters } from "../hooks/useRecentFilters"; import { useResponseViewMode } from "../hooks/useResponseViewMode"; import { useSaveResponse } from "../hooks/useSaveResponse"; import { useTimelineViewMode } from "../hooks/useTimelineViewMode"; -import { getMimeTypeFromContentType } from "../lib/contentType"; -import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util"; -import { ConfirmLargeResponse } from "./ConfirmLargeResponse"; -import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest"; -import { Button } from "./core/Button"; -import { CountBadge } from "./core/CountBadge"; +import { httpResponseToView } from "../lib/httpViews"; +import { getCookieCounts } from "../lib/model_util"; +import { CopyButton } from "./CopyButton"; import { HotkeyList } from "./core/HotkeyList"; -import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag"; -import { HttpStatusTag } from "./core/HttpStatusTag"; +import { IconButton } from "./core/IconButton"; import { PillButton } from "./core/PillButton"; -import { SizeTag } from "./core/SizeTag"; -import type { TabItem } from "./core/Tabs/Tabs"; -import { TabContent, Tabs } from "./core/Tabs/Tabs"; -import { Tooltip } from "./core/Tooltip"; -import { EmptyStateText } from "./EmptyStateText"; -import { ErrorBoundary } from "./ErrorBoundary"; import { HttpResponseTimeline } from "./HttpResponseTimeline"; import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown"; import { RequestVersionDropdown } from "./RequestVersionDropdown"; -import { RequestBodyViewer } from "./RequestBodyViewer"; -import { ResponseCookies } from "./ResponseCookies"; -import { ResponseHeaders } from "./ResponseHeaders"; -import { AudioViewer } from "./responseViewers/AudioViewer"; -import { CsvViewer } from "./responseViewers/CsvViewer"; import { EventStreamViewer } from "./responseViewers/EventStreamViewer"; -import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer"; -import { ImageViewer } from "./responseViewers/ImageViewer"; -import { MultipartViewer } from "./responseViewers/MultipartViewer"; -import { SvgViewer } from "./responseViewers/SvgViewer"; -import { VideoViewer } from "./responseViewers/VideoViewer"; - -const PdfViewer = lazy(() => - import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })), -); +import { RecentFiltersDropdown } from "./responseViewers/RecentFiltersDropdown"; interface Props { style?: CSSProperties; @@ -53,10 +40,6 @@ interface Props { activeRequestId: string; } -const TAB_BODY = "body"; -const TAB_REQUEST = "request"; -const TAB_HEADERS = "headers"; -const TAB_COOKIES = "cookies"; const TAB_TIMELINE = "timeline"; export type TimelineViewMode = "timeline" | "text"; @@ -66,12 +49,11 @@ interface RedirectDropWarning { droppedHeaders: string[]; } +/** The shared response pane, bound to the pinned response of the active request */ export function HttpResponsePane({ style, className, activeRequestId }: Props) { const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId); const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId); const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode(); - const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null); - const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence; const responseEvents = useHttpResponseEvents(activeResponse); const redirectDropWarning = useMemo( @@ -84,281 +66,184 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]); const saveResponse = useSaveResponse(activeResponse ?? null); const copyResponse = useCopyHttpResponse(activeResponse ?? null); + const cancel = useCancelHttpResponse(activeResponse?.id ?? null); + const body = useResponseBodyLoader(activeResponse ?? null); + const requestBody = useRequestBodyLoader(activeResponse ?? null); + const textViewer = useResponseTextViewerExtras(activeResponse ?? null); - const tabs = useMemo( - () => [ - { - value: TAB_BODY, - label: "Response", - options: { - value: viewMode, - onChange: setViewMode, - items: [ - { label: "Response", value: "pretty" }, - ...(mimeType?.startsWith("image") - ? [] - : [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]), - ], - itemsAfter: [ - { - label: "Save to File", - onSelect: saveResponse.mutate, - leftSlot: , - hidden: activeResponse == null || !!activeResponse.error, - disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100, - }, - { - label: "Copy Body", - onSelect: copyResponse.mutate, - leftSlot: , - hidden: activeResponse == null || !!activeResponse.error, - disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100, - }, - ], - }, - }, - { - value: TAB_REQUEST, - label: "Request", - rightSlot: - (activeResponse?.requestContentLength ?? 0) > 0 ? : null, - }, - { - value: TAB_HEADERS, - label: "Headers", - rightSlot: ( - - ), - }, - { - value: TAB_COOKIES, - label: "Cookies", - rightSlot: - cookieCounts.sent > 0 || cookieCounts.received > 0 ? ( - - ) : null, - }, - { - value: TAB_TIMELINE, - rightSlot: , - options: { - value: timelineViewMode, - onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"), - items: [ - { label: "Timeline", value: "timeline" }, - { label: "Timeline (Text)", shortLabel: "Timeline", value: "text" }, - ], - }, - }, - ], - [ - activeResponse?.headers, - activeResponse, - activeResponse?.error, - activeResponse?.requestContentLength, - activeResponse?.requestHeaders.length, - activeResponse?.state, - activeResponse?.status, - cookieCounts.sent, - cookieCounts.received, - copyResponse.mutate, - mimeType, - responseEvents.data?.length, - saveResponse.mutate, - setViewMode, - viewMode, - timelineViewMode, - setTimelineViewMode, - ], + const responseView = useMemo( + () => (activeResponse == null ? null : httpResponseToView(activeResponse, responseEvents.data)), + [activeResponse, responseEvents.data], ); - const cancel = useCancelHttpResponse(activeResponse?.id ?? null); + const extraTabs = useMemo( + () => + activeResponse == null + ? [] + : [ + { + tab: { + value: TAB_TIMELINE, + rightSlot: , + options: { + value: timelineViewMode, + onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"), + items: [ + { label: "Timeline", value: "timeline" }, + { label: "Timeline (Text)", shortLabel: "Timeline", value: "text" }, + ], + }, + }, + content: ( + + ), + }, + ], + [activeResponse, responseEvents.data?.length, setTimelineViewMode, timelineViewMode], + ); + + const renderBodyViewer = useCallback( + ({ mimeType, viewMode }: HostBodyViewerArgs) => + activeResponse != null && mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? ( + + ) : null, + [activeResponse], + ); return ( -
- {activeResponse == null ? ( + className={className} + response={responseView} + body={body} + requestBody={requestBody} + emptyState={ - ) : ( -
- - {activeResponse && ( -
+ Redirect changed this request + {redirectDropWarning.droppedBodyCount > 0 && ( + + Body dropped on {redirectDropWarning.droppedBodyCount}{" "} + {redirectDropWarning.droppedBodyCount === 1 ? "redirect hop" : "redirect hops"} + )} - > - - {activeResponse.state !== "closed" && } - - • - - • - - - {shouldShowRedirectDropWarning ? ( - - - Redirect changed this request - - {redirectDropWarning.droppedBodyCount > 0 && ( - - Body dropped on {redirectDropWarning.droppedBodyCount}{" "} - {redirectDropWarning.droppedBodyCount === 1 - ? "redirect hop" - : "redirect hops"} - - )} - {redirectDropWarning.droppedHeaders.length > 0 && ( - - Headers dropped:{" "} - - {redirectDropWarning.droppedHeaders.join(", ")} - - - )} - See Timeline for details. - - } - > - - } - > - - {getRedirectWarningLabel(redirectDropWarning)} - - + {redirectDropWarning.droppedHeaders.length > 0 && ( + + Headers dropped:{" "} + + {redirectDropWarning.droppedHeaders.join(", ")} - - ) : ( - + )} - - - - -
- )} + See Timeline for details. + + } + > + + } + > + {getRedirectWarningLabel(redirectDropWarning)} + + + + ) : null + } + statusLineRight={ + activeResponse != null && ( + + + - -
- {activeResponse?.error && ( - - {activeResponse.error} - - )} - {/* Show tabs if we have any data (headers, body, etc.) even if there's an error */} - - - - - - {activeResponse.state === "initialized" ? ( - - - - - Sending Request - - - - - ) : activeResponse.state === "closed" && - (activeResponse.contentLength ?? 0) === 0 ? ( - Empty - ) : mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? ( - - ) : mimeType?.match(/^image\/svg/) ? ( - - ) : mimeType?.match(/^image/i) ? ( - - ) : mimeType?.match(/^audio/i) ? ( - - ) : mimeType?.match(/^video/i) ? ( - - ) : mimeType?.match(/^multipart/i) && viewMode === "pretty" ? ( - - ) : mimeType?.match(/pdf/i) ? ( - - ) : mimeType?.match(/csv|tab-separated/i) && viewMode === "pretty" ? ( - - ) : ( - - )} - - - - - - - - - - - - - - - - - - - -
-
- )} -
+ ) + } + /> ); } +/** The client's additions to the response text viewer: filter history, save and copy */ +function useResponseTextViewerExtras( + response: HttpResponse | null, +): ResponseTextViewerExtras | undefined { + const filterStateKey = `response.body.${response?.requestId ?? ""}`; + const { recentFilters, addFilter, removeFilter, togglePin, clearFilters } = + useRecentFilters(filterStateKey); + const saveResponse = useSaveResponse(response); + const copyResponse = useCopyHttpResponse(response); + const filterLeftSlot = useCallback( + (filter: TextFilterApi) => ( +
+ +
+ ), + [recentFilters, removeFilter, togglePin, clearFilters], + ); + + if (response == null) return undefined; + const actionsDisabled = response.state !== "closed" && response.status >= 100; + return { + filterStateKey, + onFilterApplied: addFilter, + filterLeftSlot, + footerActions: [ + saveResponse.mutate()} + className="border !border-border-subtle" + />, + copyResponse.mutate()} + className="border !border-border-subtle" + />, + ], + }; +} + +function renderCopyButton(getText: () => Promise) { + return ; +} + function getRedirectDropWarning( events: HttpResponseEvent[] | undefined, ): RedirectDropWarning | null { @@ -406,58 +291,3 @@ function getRedirectWarningLabel(warning: RedirectDropWarning): string { } return "Dropped headers"; } - -function EnsureCompleteResponse({ - response, - Component, -}: { - response: HttpResponse; - Component: ComponentType<{ bodyUrl: string }>; -}) { - // Wait until the response has been fully-downloaded before asking for it - const complete = response.state === "closed"; - const bodyUrl = useResponseBodyUrl(complete ? response : null); - - if (!complete || bodyUrl.isPending) { - return ( - - - - ); - } - - if (bodyUrl.error) { - return {String(bodyUrl.error)}; - } - - if (bodyUrl.data == null) { - return
Empty response body
; - } - - return ; -} - -function HttpSvgViewer({ response }: { response: HttpResponse }) { - const body = useResponseBodyText({ response, filter: null }); - - if (!body.data) return null; - - return ; -} - -function HttpCsvViewer({ response, className }: { response: HttpResponse; className?: string }) { - const body = useResponseBodyText({ response, filter: null }); - - return ; -} - -function HttpMultipartViewer({ response }: { response: HttpResponse }) { - const body = useResponseBodyBytes({ response }); - - if (body.data == null) return null; - - const contentTypeHeader = getContentTypeFromHeaders(response.headers); - const boundary = contentTypeHeader?.split("boundary=")[1] ?? "unknown"; - - return ; -} diff --git a/apps/yaak-client/components/HttpResponseTimeline.tsx b/apps/yaak-client/components/HttpResponseTimeline.tsx index 4475b4a0c..72b05a382 100644 --- a/apps/yaak-client/components/HttpResponseTimeline.tsx +++ b/apps/yaak-client/components/HttpResponseTimeline.tsx @@ -13,9 +13,8 @@ import { resolvedModelName } from "../lib/resolvedModelName"; import { Editor } from "./core/Editor/LazyEditor"; import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer"; import { EventViewerRow } from "./core/EventViewerRow"; -import { HttpStatusTagRaw } from "./core/HttpStatusTag"; -import { Icon, type IconProps } from "@yaakapp-internal/ui"; -import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow"; +import { Icon, type IconProps, HttpStatusTagRaw, KeyValueRows } from "@yaakapp-internal/ui"; +import { KeyValueRow } from "./core/KeyValueRow"; import type { TimelineViewMode } from "./HttpResponsePane"; interface Props { diff --git a/apps/yaak-client/components/ImportDataDialog.tsx b/apps/yaak-client/components/ImportDataDialog.tsx index 951613017..43e698e06 100644 --- a/apps/yaak-client/components/ImportDataDialog.tsx +++ b/apps/yaak-client/components/ImportDataDialog.tsx @@ -1,33 +1,35 @@ 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 { Banner, FormattedError, HStack, Icon, VStack, IconTooltip } from "@yaakapp-internal/ui"; import classNames from "classnames"; -import { formatDistanceToNowStrict } from "date-fns"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { pluralize } from "../lib/pluralize"; +import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { errorMessage } from "../lib/errorMessage"; import { CommercialUseBanner } from "./CommercialUseBanner"; -import { Button } from "./core/Button"; -import { Checkbox } from "./core/Checkbox"; +import { + ImportSourceList, + type ImportSourceDetection, + type ImportSourcePath, + InlineButton, + toSourcePath, +} from "./ImportSourceList"; import type { CheckboxTreeNode } from "./core/CheckboxTree"; import { CheckboxTree } from "./core/CheckboxTree"; -import { IconTooltip } from "./core/IconTooltip"; -import { PlainInput } from "./core/PlainInput"; +import { Chip } from "./core/Chip"; +import { DialogFooter } from "./core/Dialog"; import { Select } from "./core/Select"; import { SegmentedControl } from "./core/SegmentedControl"; +import { pluralize } from "@yaakapp-internal/lib/pluralize"; interface Props { currentWorkspace: Workspace | null; workspaces: Workspace[]; - selectedFolder: Folder | null; - planFile: (filePath: string, destination: ImportDestination) => Promise; - planUrl: (url: string, destination: ImportDestination) => Promise; + planSources: (sources: ImportSourcePath[], destination: ImportDestination) => Promise; + detectSource: (source: ImportSourcePath) => Promise; listSources: (workspaceId: string) => Promise; findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise; commit: (plan: ImportPlan) => Promise; @@ -35,25 +37,6 @@ interface Props { onError: (err: unknown) => void; } -/** - * An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a - * bare host like `example.com/openapi.json` still works (the backend defaults it to https). - */ -function isFilePath(value: string): boolean { - return ( - value.startsWith("/") || - value.startsWith("./") || - value.startsWith("../") || - value.startsWith("~/") || - value.startsWith("\\\\") || - /^[a-zA-Z]:[\\/]/.test(value) - ); -} - -function fileName(path: string): string { - return path.split(/[/\\]/).at(-1) || path; -} - /** * 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 @@ -82,30 +65,19 @@ export function ImportDataDialog(props: Props) { return ; } -function latestSource(sources: ImportSource[]): ImportSource | null { - return sources.reduce( - (latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest), - null, - ); -} - function LoadedImportDataDialog({ currentWorkspace, workspaces, - selectedFolder, - planFile, - planUrl, - listSources, + planSources, + detectSource, 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(false); + const [sourceError, setSourceError] = useState(null); const [plan, setPlan] = useState(null); const [items, setItems] = useState([]); // null means no explicit choice yet, so the default below applies @@ -113,65 +85,82 @@ function LoadedImportDataDialog({ null, ); const [otherWorkspaceId, setOtherWorkspaceId] = useState(null); - const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null); - const [linkedSources, setLinkedSources] = useState( - prefill != null ? initialSources : [], + const [originSources, setOriginSources] = useState(initialSources); + const initialPaths = useMemo( + () => [...new Set(initialSources.map((source) => source.origin))].map(toSourcePath), + [initialSources], ); - const [originSources, setOriginSources] = useState( - prefill != null ? [prefill] : [], + const [sources, setSources] = useState(initialPaths); + const knownPaths = useRef(new Set(initialPaths.map((source) => source.path))); + const [detections, setDetections] = useState>({}); + const detect = useCallback( + (added: ImportSourcePath[]) => { + setDetections((prev) => ({ + ...prev, + ...Object.fromEntries(added.map((s) => [s.path, { status: "loading" as const }])), + })); + for (const source of added) { + const settle = (detection: ImportSourceDetection) => + setDetections((prev) => + source.path in prev ? { ...prev, [source.path]: detection } : prev, + ); + detectSource(source).then( + (importer) => settle({ status: "ok", importer }), + (err) => settle({ status: "error", message: errorMessage(err) }), + ); + } + }, + [detectSource], ); - // A file path or a URL. Both inputs write here, so there is only ever one thing to import - const [source, setSource] = useState(prefill?.origin ?? null); - const [forceUpdateKey, setForceUpdateKey] = useState(0); - const [isHovering, setIsHovering] = useState(false); - const ref = useRef(null); - const trimmedSource = source?.trim() ?? ""; - const filePath = isFilePath(trimmedSource) ? trimmedSource : null; + const allDetected = sources.every((source) => detections[source.path]?.status === "ok"); + useEffect(() => { + detect(initialPaths); + }, [detect, initialPaths]); - const selectSource = (value: string) => { - setSource(value); - // Remount the input so it shows the path of the newly-picked file - setForceUpdateKey((k) => k + 1); + const addSources = useCallback( + (added: ImportSourcePath[]) => { + setSourceError(null); + const fresh = added.filter(({ path }) => { + if (knownPaths.current.has(path)) return false; + knownPaths.current.add(path); + return true; + }); + if (fresh.length === 0) return; + setSources((existing) => [...existing, ...fresh]); + detect(fresh); + }, + [detect], + ); + const removeSource = (path: string) => { + setSourceError(null); + knownPaths.current.delete(path); + setSources((current) => current.filter((source) => source.path !== path)); + setDetections(({ [path]: _removed, ...rest }) => rest); }; - // Accept a file dropped anywhere on the dialog, the way SelectFile does for its button useEffect(() => { - return platform.window.onDragDrop((event) => { - if (event.type === "over") { - const p = event.position; - const r = ref.current?.getBoundingClientRect(); - if (r == null) return; - setIsHovering(p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom); - } else if (event.type === "drop" && isHovering) { - const p = event.paths[0]; - if (p) selectSource(p); - setIsHovering(false); - } else { - setIsHovering(false); - } - }); - }, [isHovering, setSource]); - - useEffect(() => { - if (trimmedSource === "") { + let cancelled = false; + if (sources.length === 0) { setOriginSources([]); return; } - let cancelled = false; - const timeout = setTimeout(() => { - findSourcesForOrigin(filePath != null ? { filePath } : { url: trimmedSource }) - .then((sources) => { - if (!cancelled) setOriginSources(sources); - }) - .catch(() => setOriginSources([])); - }, 300); + Promise.all( + sources.map(({ path, kind }) => + findSourcesForOrigin(kind === "url" ? { url: path } : { filePath: path }), + ), + ) + .then((found) => { + if (!cancelled) setOriginSources(found.flat()); + }) + .catch(() => { + if (!cancelled) setOriginSources([]); + }); return () => { cancelled = true; - clearTimeout(timeout); }; - }, [trimmedSource, filePath, findSourcesForOrigin]); + }, [sources, findSourcesForOrigin]); - // The one workspace this file is linked to, if there is exactly one. + // The one workspace these sources are 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; @@ -193,32 +182,6 @@ function LoadedImportDataDialog({ ? 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); - }; - - // 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" }; @@ -226,21 +189,18 @@ function LoadedImportDataDialog({ return { type: "existing_workspace", workspaceId: destinationWorkspaceId, - folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined, }; }; const handlePreview = async () => { setIsLoading(true); + setSourceError(null); try { - const nextPlan = - filePath != null - ? await planFile(filePath, destination()) - : await planUrl(trimmedSource, destination()); + const nextPlan = await planSources(sources, destination()); setPlan(nextPlan); setItems(nextPlan.items); } catch (err) { - onError(err); + setSourceError(err); } finally { setIsLoading(false); } @@ -258,16 +218,17 @@ function LoadedImportDataDialog({ } }; - 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. - const toggleNode = (node: CheckboxTreeNode, checked: boolean) => { - const targets = new Set( - collectItems(node) - .filter((i) => togglesWith(node.data, i)) - .map((i) => i.modelId), - ); + // A folder row's checkbox carries everything beneath it, deletions included — the row labels + // say which of those are destructive. Checking anything also brings back the folders it needs + // to live in. + const toggleNode = (node: CheckboxTreeNode, checked: boolean) => { + const targets = new Set(togglableItems(node).map((i) => i.modelId)); + if (checked && node.data.kind === "item") { + const byId = new Map(items.map((i) => [i.modelId, i])); + for (const ancestor of ancestorsOf(node.data.item, byId)) { + if (isMissingFolder(ancestor)) targets.add(ancestor.modelId); + } + } setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i))); }; @@ -277,25 +238,16 @@ function LoadedImportDataDialog({ ); }; - // 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. + // Deleting a folder takes its contents with it, so those rows have nothing left to decide. const disabledIds = useMemo(() => { const disabled = new Set(); const byId = new Map(items.map((i) => [i.modelId, i])); for (const item of items) { - const seen = new Set(); - let parentId = item.parentId; - while (parentId != null && !seen.has(parentId)) { - seen.add(parentId); - const parent = byId.get(parentId); - if (parent == null || parent.model !== "folder") break; - if (parent.action === "create" && !parent.selected && item.action !== "delete") { + if (item.action !== "delete") continue; + for (const parent of ancestorsOf(item, byId)) { + if (parent.action === "delete" && parent.selected) { disabled.add(item.modelId); } - if (parent.action === "delete" && parent.selected && item.action === "delete") { - disabled.add(item.modelId); - } - parentId = parent.parentId; } } return disabled; @@ -314,193 +266,220 @@ function LoadedImportDataDialog({ return item.selected; }).length; - const destinationLabel = (() => { - if (plan.destination.type === "new_workspace") return "New workspace"; - const { workspaceId, folderId } = plan.destination; + // The row's label carries what kind of destination it is, so the value can just be its name + const [destinationLabel, destinationValue] = ((): [string, string] => { + if (plan.destination.type === "new_workspace") { + const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== ""); + if (names.length === 0) return ["New workspace", "Untitled"]; + return [ + names.length === 1 ? "New workspace" : `${names.length} new workspaces`, + names.join(", "), + ]; + } + const { workspaceId } = plan.destination; const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace"; - return folderId != null && folderId === selectedFolder?.id - ? `${name} / ${selectedFolder.name}` - : name; + return ["Destination", 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 = (() => { - const planned = plan.resources.workspaces[0]; + const workspaceRoots: CheckboxTreeNode[] = (() => { 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", + const destinations = existing ? [existing] : plan.resources.workspaces; + if (destinations.length === 1) { + const workspace = destinations[0]!; + return [ + { + key: workspace.id, + data: { + kind: "destination", + label: workspace.name, + isNew: planDestination.type === "new_workspace", + }, + children: buildItemTree(items), + }, + ]; + } + const workspaceById = new Map( + [ + ...plan.resources.environments, + ...plan.resources.folders, + ...plan.resources.httpRequests, + ...plan.resources.grpcRequests, + ...plan.resources.websocketRequests, + ].map((resource) => [resource.id, resource.workspaceId]), + ); + return destinations.map((workspace) => ({ + key: workspace.id, 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, + kind: "destination", + label: workspace.name, + isNew: planDestination.type === "new_workspace", }, - children: itemTree, - }; + children: buildItemTree( + items.filter((item) => workspaceById.get(item.modelId) === workspace.id), + ), + })); })(); return ( - -
- - -
- -
- disabledIds.has(n.key)} - isRelevant={(n) => n.data.model === "workspace" || n.data.action !== "unchanged"} - renderRow={(n) => } - /> -
- - {plan.warnings.length > 0 && ( -
-
Import details
-
- {plan.warnings.map((warning) => ( -
- -
-
{warning.title}
-
{warning.detail}
-
-
- ))} -
+ <> + +
+ +
- )} - - {footerNote !== "" &&
{footerNote}
} - - -
-
+ {plan.warnings + .filter( + (warning, index, warnings) => + warnings.findIndex( + (other) => + other.title === warning.title && + other.detail === warning.detail && + other.level === warning.level, + ) === index, + ) + .map((warning) => ( + + +
+
{warning.title}
+
{warning.detail}
+
+
+ ))} + + {workspaceRoots.map((root) => ( +
+ disabledIds.has(n.key)} + isCollapsedByDefault={(n) => + (n.data.kind === "destination" && workspaceRoots.length > 1) || + (n.data.kind === "item" && n.data.item.action === "ignored") + } + isRelevant={(n) => + n.data.kind === "destination" || + (n.data.kind === "item" && n.data.item.action !== "unchanged") + } + renderRow={(n) => ( + + )} + /> +
+ ))} + + {footerNote}
+ ) : undefined + } + actions={[ + { + label: "Back", + disabled: isLoading, + onClick: () => { + setPlan(null); + setItems([]); + }, + }, + { + label: isLoading + ? "Importing" + : changeCount > 0 + ? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}` + : "Done", + color: "primary", + isLoading, + onClick: handleCommit, + }, + ]} + /> + ); } - const lastImported = - originSources.find((s) => s.workspaceId === destinationWorkspaceId) ?? - linkedSources.reduce( - (latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest), - null, - ); - - return ( - - - - - - + setOtherWorkspaceId(id === "" ? null : id)} + filterable options={[ - { value: "new", label: "New Workspace" }, - ...(currentWorkspace != null - ? [{ value: "current" as const, label: "Current Workspace" }] - : []), - { value: "other", label: "Other Workspace" }, + { value: "", label: "Select a workspace" }, + ...workspaces + .filter((w) => w.id !== currentWorkspace?.id) + .map((w) => ({ value: w.id, label: w.name })), ]} /> - {destinationKind === "other" && ( - ; + }, +})); + +beforeAll(async () => { + const base = pathToFileURL( + resolve(import.meta.dirname, "../../../crates/yaak-templates/pkg") + "/", + ); + const bindings = await import(new URL("yaak_templates_bg.js", base).href); + const { instance } = await WebAssembly.instantiate( + await readFile(new URL("yaak_templates_bg.wasm", base)), + { "./yaak_templates_bg.js": bindings }, + ); + bindings.__wbg_set_wasm(instance.exports); + (instance.exports.__wbindgen_start as () => void)(); + mocks.parse.mockImplementation(bindings.parse_template); +}); + +let root: Root; +let host: HTMLDivElement; +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + mocks.form = null; + // The transport returns an opaque string. Carry the AST as JSON so the assertions can + // inspect what Preview and Save receive, without duplicating the backend's printer. + mocks.serialize.mockImplementation((tokens) => ({ data: JSON.stringify(tokens) })); + mocks.preview.mockImplementation(({ template }) => ({ data: template })); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); +}); + +function functionValue(tokens: Tokens) { + const first = tokens.tokens[0]; + if (first?.type !== "tag" || first.val.type !== "fn") throw new Error("Expected function"); + return first.val; +} + +test.each([ + ["timestamp.offset(expression='-1h')", "${[ timestamp.offset(expression='-1h') ]}"], + ["date_var", "${[ date_var ]}"], + ["dates.previous", "${[ dates.previous ]}"], + ["'${[ date_var ]}'", "${[ date_var ]}"], +])("Preview and Save preserve %s until that input changes", async (expression, display) => { + const initialTokens = mocks.parse(`\${[ timestamp.format(date=${expression}, format='HH:mm') ]}`); + const originalDate = functionValue(initialTokens).args[0]; + const saved = vi.fn<(value: string) => void>(); + mocks.print.mockResolvedValue(display); + + await act(async () => + root.render( + {}} + />, + ), + ); + expect(host.querySelector("input")?.value).toBe(display); + + async function submit() { + const form = host.querySelector("form"); + if (form == null) throw new Error("Dialog did not initialize"); + await act(async () => { + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); + const value = saved.mock.calls.at(-1)?.[0]; + expect(value).toBe(mocks.preview.mock.calls.at(-1)?.[0].template); + return functionValue(JSON.parse(value ?? "null") as Tokens).args; + } + + expect((await submit())[0]).toEqual(originalDate); + await act(async () => mocks.form?.onChange({ ...mocks.form.data, format: "yyyy" })); + expect(await submit()).toEqual([ + originalDate, + { name: "format", value: { type: "str", text: "yyyy" } }, + ]); + + const edited = "${[ timestamp.offset(expression='-2h') ]}"; + await act(async () => mocks.form?.onChange({ ...mocks.form.data, date: edited })); + expect((await submit())[0]).toEqual({ name: "date", value: { type: "str", text: edited } }); + await act(async () => mocks.form?.onChange({ ...mocks.form.data, date: "" })); + expect((await submit())[0]).toEqual({ name: "date", value: { type: "str", text: "" } }); +}); diff --git a/apps/yaak-client/components/TemplateFunctionDialog.tsx b/apps/yaak-client/components/TemplateFunctionDialog.tsx index 92e217720..fecbe099d 100644 --- a/apps/yaak-client/components/TemplateFunctionDialog.tsx +++ b/apps/yaak-client/components/TemplateFunctionDialog.tsx @@ -11,7 +11,7 @@ import type { FnArg, Tokens } from "@yaakapp-internal/templates"; import { parseTemplate } from "@yaakapp-internal/templates"; import { HStack, InlineCode, LoadingIcon, useDebouncedValue } from "@yaakapp-internal/ui"; import classNames from "classnames"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useId, useMemo, useState } from "react"; import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace"; import { useRenderTemplate } from "../hooks/useRenderTemplate"; import { useTemplateFunctionConfig } from "../hooks/useTemplateFunctionConfig"; @@ -24,8 +24,11 @@ import { showDialog } from "../lib/dialog"; import { convertTemplateToInsecure } from "../lib/encryption"; import { jotaiStore } from "../lib/jotai"; import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption"; -import { Button } from "./core/Button"; -import { collectArgumentValues } from "./core/Editor/twig/util"; +import { + collectEditableArgumentValues, + preserveUnchangedArgumentExpressions, +} from "../lib/templateFunctionArguments"; +import { DialogFooter } from "./core/Dialog"; import { IconButton } from "./core/IconButton"; import { PlainInput } from "./core/PlainInput"; import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from "./DynamicForm"; @@ -48,7 +51,11 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro } (async () => { - const initial = collectArgumentValues(initialTokens, templateFunction); + const initial = await collectEditableArgumentValues( + initialTokens, + templateFunction, + templateTokensToString, + ); // HACK: Replace the secure() function's encrypted `value` arg with the decrypted version so // we can display it in the editor input. @@ -74,6 +81,7 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro ); @@ -82,15 +90,17 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro function InitializedTemplateFunctionDialog({ templateFunction: { name, previewType: ogPreviewType }, initialArgValues, + initialTokens, hide, onChange, model, -}: Omit & { +}: Props & { initialArgValues: Record; }) { const previewType = ogPreviewType == null ? "live" : ogPreviewType; const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false); const [argValues, setArgValues] = useState>(initialArgValues); + const formId = useId(); const tokens: Tokens = useMemo(() => { const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({ @@ -110,12 +120,17 @@ function InitializedTemplateFunctionDialog({ val: { type: "fn", name, - args: argTokens, + args: preserveUnchangedArgumentExpressions( + argTokens, + initialTokens, + initialArgValues, + argValues, + ), }, }, ], }; - }, [argValues, name]); + }, [argValues, initialArgValues, initialTokens, name]); const tagText = useTemplateTokensToString(tokens); const templateFunction = useTemplateFunctionConfig(name, argValues, model); @@ -160,13 +175,14 @@ function InitializedTemplateFunctionDialog({ return (
{ e.preventDefault(); handleDone(); }} > -
+
{name === "secure" ? ( )}
-
- {previewType !== "none" ? ( -
- - - Rendered Preview - {rendered.isLoading && } - + {previewType !== "none" ? ( +
+ + + Rendered Preview + {rendered.isLoading && } + + + +
+ + {rendered.error || tagText.error ? ( + + {`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")} + + ) : dataContainsSecrets && !showSecretsInPreview ? ( + + ------ sensitive values hidden ------ + + ) : tooLarge ? ( + "too large to preview" + ) : ( + rendered.data || <>  + )} + +
{ + setRenderKey(new Date().toISOString()); + }} /> - -
- - {rendered.error || tagText.error ? ( - - {`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")} - - ) : dataContainsSecrets && !showSecretsInPreview ? ( - - ------ sensitive values hidden ------ - - ) : tooLarge ? ( - "too large to preview" - ) : ( - rendered.data || <>  - )} - -
- { - setRenderKey(new Date().toISOString()); - }} - /> -
- ) : ( - - )} -
- {templateFunction.data.name === "secure" && ( - - )} -
-
+ ) : ( + + )} + ); } @@ -272,7 +284,6 @@ TemplateFunctionDialog.show = ( id: `template-function-${Math.random()}`, // Allow multiple at once size: "md", className: "h-240", - noPadding: true, title: {fn.name}(…), description: fn.description, render: ({ hide }) => { diff --git a/apps/yaak-client/components/Toasts.tsx b/apps/yaak-client/components/Toasts.tsx index 046f1919a..95e2f4253 100644 --- a/apps/yaak-client/components/Toasts.tsx +++ b/apps/yaak-client/components/Toasts.tsx @@ -1,43 +1,6 @@ -import { useAtomValue } from "jotai"; -import { AnimatePresence } from "motion/react"; -import type { ReactNode } from "react"; -import { hideToast, toastsAtom } from "../lib/toast"; -import { Toast, type ToastProps } from "./core/Toast"; -import { ErrorBoundary } from "./ErrorBoundary"; -import { Portal } from "@yaakapp-internal/ui"; +import { Toasts as BaseToasts } from "@yaakapp-internal/ui"; +import { showErrorDialog } from "./ErrorBoundary"; -export type ToastInstance = { - id: string; - uniqueKey: string; - message: ReactNode; - timeout: 3000 | 5000 | 8000 | (number & {}) | null; - onClose?: ToastProps["onClose"]; -} & Omit; +export type { ToastInstance } from "@yaakapp-internal/ui"; -export const Toasts = () => { - const toasts = useAtomValue(toastsAtom); - return ( - -
- - {toasts.map((toast: ToastInstance) => { - const { message, uniqueKey, ...props } = toast; - return ( - - hideToast(toast)} - > - {message} - - - ); - })} - -
-
- ); -}; +export const Toasts = () => ; diff --git a/apps/yaak-client/components/UrlBar.tsx b/apps/yaak-client/components/UrlBar.tsx index b729ad3b1..bdec9c87f 100644 --- a/apps/yaak-client/components/UrlBar.tsx +++ b/apps/yaak-client/components/UrlBar.tsx @@ -1,113 +1,14 @@ -import type { HttpRequest } from "@yaakapp-internal/models"; -import type { IconProps } from "@yaakapp-internal/ui"; -import { HStack } from "@yaakapp-internal/ui"; -import classNames from "classnames"; -import type { FormEvent, ReactNode } from "react"; -import { memo, useCallback, useRef, useState } from "react"; -import { useHotKey } from "../hooks/useHotKey"; -import { IconButton } from "./core/IconButton"; -import type { InputHandle, InputProps } from "./core/Input"; -import { Input } from "./core/Input"; +import { UrlBar as BaseUrlBar, type UrlBarProps } from "@yaakapp-internal/ui"; +import { memo } from "react"; -type Props = Pick & { - className?: string; - placeholder: string; +type Props = Omit & { onSend: () => void; onUrlChange: (url: string) => void; - onPaste?: (v: string) => void; - onPasteOverwrite?: InputProps["onPasteOverwrite"]; onCancel: () => void; - submitIcon?: IconProps["icon"] | null; isLoading: boolean; - forceUpdateKey: string; - rightSlot?: ReactNode; - leftSlot?: ReactNode; - autocomplete?: InputProps["autocomplete"]; - stateKey: InputProps["stateKey"]; }; -export const UrlBar = memo(function UrlBar({ - forceUpdateKey, - onUrlChange, - url, - placeholder, - className, - onSend, - onCancel, - onPaste, - onPasteOverwrite, - submitIcon = "send_horizontal", - autocomplete, - leftSlot, - rightSlot, - isLoading, - stateKey, -}: Props) { - const inputRef = useRef(null); - const [isFocused, setIsFocused] = useState(false); - - const handleInitInputRef = useCallback((h: InputHandle | null) => { - inputRef.current = h; - }, []); - - useHotKey("url_bar.focus", () => { - inputRef.current?.selectAll(); - }); - - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - if (isLoading) onCancel(); - else onSend(); - }; - - return ( -
- setIsFocused(true)} - onBlur={() => setIsFocused(false)} - onPaste={onPaste} - onPasteOverwrite={onPasteOverwrite} - onChange={onUrlChange} - defaultValue={url} - placeholder={placeholder} - leftSlot={leftSlot} - rightSlot={ - - {rightSlot &&
{rightSlot}
} - {submitIcon !== null && ( -
- { - // Prevent the button from taking focus - e.preventDefault(); - }} - /> -
- )} -
- } - /> -
- ); +/** The shared URL bar, sending and focusing on the client's hotkeys */ +export const UrlBar = memo(function UrlBar(props: Props) { + return ; }); diff --git a/apps/yaak-client/components/UrlParameterEditor.tsx b/apps/yaak-client/components/UrlParameterEditor.tsx index aab2afad5..321993c19 100644 --- a/apps/yaak-client/components/UrlParameterEditor.tsx +++ b/apps/yaak-client/components/UrlParameterEditor.tsx @@ -1,8 +1,11 @@ -import { VStack } from "@yaakapp-internal/ui"; +import { + type EditablePair, + type PairEditorHandle, + type PairEditorProps, + UrlParametersEditor as BaseUrlParametersEditor, +} from "@yaakapp-internal/ui"; import { useCallback, useRef } from "react"; import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor"; -import type { EditablePair, PairEditorHandle, PairEditorProps } from "./core/PairEditor"; -import { PairOrBulkEditor } from "./core/PairOrBulkEditor"; type Props = { forceUpdateKey: string; @@ -11,6 +14,7 @@ type Props = { onChange: PairEditorProps["onChange"]; }; +/** The shared parameters editor, wired to the request editor's focus events */ export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) { const pairEditorRef = useRef(null); const handleInitPairEditorRef = useCallback((ref: PairEditorHandle) => { @@ -33,22 +37,12 @@ export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey ); return ( - - - + ); } diff --git a/apps/yaak-client/components/WebsocketRequestPane.tsx b/apps/yaak-client/components/WebsocketRequestPane.tsx index 134c6f271..f4868a111 100644 --- a/apps/yaak-client/components/WebsocketRequestPane.tsx +++ b/apps/yaak-client/components/WebsocketRequestPane.tsx @@ -22,11 +22,9 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse"; import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection"; import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor"; import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey"; -import { languageFromContentType } from "../lib/contentType"; import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders"; import { prepareImportQuerystring } from "../lib/prepareImportQuerystring"; import { resolvedModelName } from "../lib/resolvedModelName"; -import { CountBadge } from "./core/CountBadge"; import type { GenericCompletionConfig } from "./core/Editor/genericCompletion"; import { getUrlCompletionConfig } from "./core/Editor/url/completion"; import { Editor } from "./core/Editor/LazyEditor"; @@ -40,6 +38,7 @@ import { MarkdownEditor } from "./MarkdownEditor"; import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor"; import { UrlBar } from "./UrlBar"; import { UrlParametersEditor } from "./UrlParameterEditor"; +import { languageFromContentType, CountBadge } from "@yaakapp-internal/ui"; interface Props { style: CSSProperties; @@ -110,6 +109,8 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque [activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder], ); + const hasDescription = activeRequest.description.trim().length > 0; + const tabs = useMemo(() => { return [ { @@ -131,9 +132,10 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque { value: TAB_DESCRIPTION, label: "Info", + rightSlot: hasDescription && , }, ]; - }, [authTab, headersTab, numSettingsOverrides, urlParameterPairs.length]); + }, [authTab, hasDescription, headersTab, numSettingsOverrides, urlParameterPairs.length]); const { activeResponse } = usePinnedHttpResponse(activeRequestId); const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null); diff --git a/apps/yaak-client/components/WebsocketResponsePane.tsx b/apps/yaak-client/components/WebsocketResponsePane.tsx index 42806e4e7..418d5ae5e 100644 --- a/apps/yaak-client/components/WebsocketResponsePane.tsx +++ b/apps/yaak-client/components/WebsocketResponsePane.tsx @@ -1,6 +1,14 @@ import type { WebsocketEvent, WebsocketRequest } from "@yaakapp-internal/models"; -import { HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui"; -import { hexy } from "hexy"; +import { + HStack, + hexdump, + HexdumpViewer, + Icon, + LoadingIcon, + VStack, + useStateWithDeps, + languageFromContentType, +} from "@yaakapp-internal/ui"; import { useAtomValue } from "jotai"; import { useMemo, useState } from "react"; import { useFormatText } from "../hooks/useFormatText"; @@ -10,10 +18,9 @@ import { setPinnedWebsocketConnectionId, useWebsocketEvents, } from "../hooks/usePinnedWebsocketConnection"; -import { useStateWithDeps } from "../hooks/useStateWithDeps"; -import { languageFromContentType } from "../lib/contentType"; import { Button } from "./core/Button"; import { Editor } from "./core/Editor/LazyEditor"; +import { renderEditor } from "./core/Editor/renderEditor"; import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer"; import { EventViewerRow } from "./core/EventViewerRow"; import { HotkeyList } from "./core/HotkeyList"; @@ -117,10 +124,10 @@ function WebsocketEventRow({ messageType === "error" ? "alert_triangle" : messageType === "close" || messageType === "open" - ? "info" - : isServer - ? "arrow_big_down_dash" - : "arrow_big_up_dash"; + ? "info" + : isServer + ? "arrow_big_down_dash" + : "arrow_big_up_dash"; const content = messageType === "close" ? ( @@ -167,7 +174,7 @@ function WebsocketEventDetail({ }) { const message = useMemo(() => { if (hexDump) { - return event.message ? hexy(event.message) : ""; + return event.message ? hexdump(event.message) : ""; } return event.message ? new TextDecoder("utf-8").decode(Uint8Array.from(event.message)) : ""; }, [event.message, hexDump]); @@ -227,6 +234,8 @@ function WebsocketEventDetail({ ) : event.message.length === 0 ? ( No Content + ) : hexDump ? ( + ) : ( ; } const header = ( diff --git a/apps/yaak-client/components/WorkspaceEncryptionSetting.tsx b/apps/yaak-client/components/WorkspaceEncryptionSetting.tsx index 43b88abe3..eadbf4790 100644 --- a/apps/yaak-client/components/WorkspaceEncryptionSetting.tsx +++ b/apps/yaak-client/components/WorkspaceEncryptionSetting.tsx @@ -5,20 +5,17 @@ import { setWorkspaceKey, } from "@yaakapp-internal/crypto"; import type { WorkspaceMeta } from "@yaakapp-internal/models"; -import { Banner, HStack, VStack } from "@yaakapp-internal/ui"; +import { Banner, HStack, VStack, useStateWithDeps, IconTooltip, Label } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtomValue } from "jotai"; import { useEffect, useState } from "react"; import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace"; import { createFastMutation } from "../hooks/useFastMutation"; -import { useStateWithDeps } from "../hooks/useStateWithDeps"; import { showConfirm } from "../lib/confirm"; import { CopyIconButton } from "./CopyIconButton"; import type { ButtonProps } from "./core/Button"; import { Button } from "./core/Button"; import { IconButton } from "./core/IconButton"; -import { IconTooltip } from "./core/IconTooltip"; -import { Label } from "./core/Label"; import { PlainInput } from "./core/PlainInput"; import { SettingRow } from "./core/SettingRow"; import { EncryptionHelp } from "./EncryptionHelp"; diff --git a/apps/yaak-client/components/WorkspaceSettingsDialog.tsx b/apps/yaak-client/components/WorkspaceSettingsDialog.tsx index 56d98a8de..a0cddc2af 100644 --- a/apps/yaak-client/components/WorkspaceSettingsDialog.tsx +++ b/apps/yaak-client/components/WorkspaceSettingsDialog.tsx @@ -1,5 +1,5 @@ import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models"; -import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui"; +import { Banner, HStack, InlineCode, CountBadge } from "@yaakapp-internal/ui"; import { useAtomValue } from "jotai"; import { useAuthTab } from "../hooks/useAuthTab"; import { useHeadersTab } from "../hooks/useHeadersTab"; @@ -9,7 +9,6 @@ import { showDialog } from "../lib/dialog"; import { router } from "../lib/router"; import { CopyIconButton } from "./CopyIconButton"; import { Button } from "./core/Button"; -import { CountBadge } from "./core/CountBadge"; import { PlainInput } from "./core/PlainInput"; import { SettingsList, SettingsSection } from "./core/SettingRow"; import { TabContent, Tabs } from "./core/Tabs/Tabs"; diff --git a/apps/yaak-client/components/core/BulkPairEditor.test.ts b/apps/yaak-client/components/core/BulkPairEditor.test.ts deleted file mode 100644 index b05998024..000000000 --- a/apps/yaak-client/components/core/BulkPairEditor.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, test } from "vite-plus/test"; -import { parseBulkPairLine } from "./BulkPairEditor"; - -describe("parseBulkPairLine", () => { - test("parses colon-space pairs as name and value", () => { - expect(parseBulkPairLine("foo: bar")).toMatchObject({ - enabled: true, - name: "foo", - value: "bar", - }); - }); - - test("preserves colon-without-space lines as a name with an empty value", () => { - expect(parseBulkPairLine("foo:bar")).toMatchObject({ - enabled: true, - name: "foo:bar", - value: "", - }); - }); - - test("preserves malformed lines instead of dropping their contents", () => { - expect(parseBulkPairLine("not a pair")).toMatchObject({ - enabled: true, - name: "not a pair", - value: "", - }); - }); - - test("unescapes newlines in parsed values", () => { - expect(parseBulkPairLine("foo: bar\\nbaz")).toMatchObject({ - enabled: true, - name: "foo", - value: "bar\nbaz", - }); - }); -}); diff --git a/apps/yaak-client/components/core/BulkPairEditor.tsx b/apps/yaak-client/components/core/BulkPairEditor.tsx deleted file mode 100644 index fe3b4bd5e..000000000 --- a/apps/yaak-client/components/core/BulkPairEditor.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useCallback, useMemo } from "react"; -import { generateId } from "../../lib/generateId"; -import { Editor } from "./Editor/LazyEditor"; -import type { Pair, PairEditorProps, PairWithId } from "./PairEditor"; - -type Props = PairEditorProps; - -export function BulkPairEditor({ - pairs, - onChange, - namePlaceholder, - valuePlaceholder, - forceUpdateKey, - forcedEnvironmentId, - stateKey, -}: Props) { - const pairsText = useMemo(() => { - return pairs - .filter((p) => !(p.name.trim() === "" && p.value.trim() === "")) - .map(formatBulkPairLine) - .join("\n"); - }, [pairs]); - - const handleChange = useCallback( - (text: string) => { - const pairs = text - .split("\n") - .filter((l: string) => l.trim()) - .map(parseBulkPairLine); - onChange(pairs); - }, - [onChange], - ); - - return ( - - ); -} - -export function formatBulkPairLine(pair: Pair) { - const value = pair.value.replaceAll("\n", "\\n"); - return `${pair.name}: ${value}`; -} - -export function parseBulkPairLine(line: string): PairWithId { - const [, name, value] = line.match(/^([^:]+):\s+(.*)$/) ?? []; - return { - enabled: true, - name: (name ?? line).trim(), - value: (value ?? "").replaceAll("\\n", "\n").trim(), - id: generateId(), - }; -} diff --git a/apps/yaak-client/components/core/Checkbox.tsx b/apps/yaak-client/components/core/Checkbox.tsx index 92b19424b..787549415 100644 --- a/apps/yaak-client/components/core/Checkbox.tsx +++ b/apps/yaak-client/components/core/Checkbox.tsx @@ -1,73 +1,2 @@ -import { HStack, Icon } from "@yaakapp-internal/ui"; -import classNames from "classnames"; -import type { ReactNode } from "react"; -import { IconTooltip } from "./IconTooltip"; - -export interface CheckboxProps { - checked: boolean | "indeterminate"; - title: ReactNode; - onChange: (checked: boolean) => void; - className?: string; - disabled?: boolean; - inputWrapperClassName?: string; - hideLabel?: boolean; - fullWidth?: boolean; - help?: ReactNode; - size?: "sm" | "md"; -} - -export function Checkbox({ - checked, - onChange, - className, - inputWrapperClassName, - disabled, - title, - hideLabel, - fullWidth, - help, - size = "sm", -}: CheckboxProps) { - return ( - -
- { - onChange(checked === "indeterminate" ? true : !checked); - }} - /> -
- -
-
- {!hideLabel && ( -
- {title} -
- )} - {help && } -
- ); -} +export type { CheckboxProps } from "@yaakapp-internal/ui"; +export { Checkbox } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/core/CheckboxTree.tsx b/apps/yaak-client/components/core/CheckboxTree.tsx index 0f714e353..d6d4423da 100644 --- a/apps/yaak-client/components/core/CheckboxTree.tsx +++ b/apps/yaak-client/components/core/CheckboxTree.tsx @@ -21,6 +21,8 @@ interface Props { isCheckboxDisabled?: (node: CheckboxTreeNode) => boolean; /** An irrelevant row is hidden unless one of its descendants is relevant */ isRelevant: (node: CheckboxTreeNode) => boolean; + /** A node that starts collapsed, so a large subtree doesn't crowd out the rest */ + isCollapsedByDefault?: (node: CheckboxTreeNode) => boolean; renderRow: (node: CheckboxTreeNode) => ReactNode; onSelectRow?: (node: CheckboxTreeNode) => void; canSelectRow?: (node: CheckboxTreeNode) => boolean; @@ -29,7 +31,9 @@ interface Props { export function CheckboxTree(props: Props) { const { node, depth = 0 } = props; - const [collapsed, setCollapsed] = useState(false); + const [collapsed, setCollapsed] = useState( + () => props.isCollapsedByDefault?.(node) ?? false, + ); if (!hasRelevantNode(node, props.isRelevant)) return null; const checked = props.checked(node); diff --git a/apps/yaak-client/components/core/Chip.tsx b/apps/yaak-client/components/core/Chip.tsx new file mode 100644 index 000000000..2b347e50d --- /dev/null +++ b/apps/yaak-client/components/core/Chip.tsx @@ -0,0 +1,32 @@ +import type { Color } from "@yaakapp-internal/plugins"; +import classNames from "classnames"; +import type { ReactNode } from "react"; + +interface Props { + color?: Color | "default"; + className?: string; + children: ReactNode; +} + +/** A small tinted label, like a Gmail or Trello label */ +export function Chip({ color = "default", className, children }: Props) { + return ( + + {children} + + ); +} diff --git a/apps/yaak-client/components/core/Confirm.tsx b/apps/yaak-client/components/core/Confirm.tsx index bc464900a..a1bd12b39 100644 --- a/apps/yaak-client/components/core/Confirm.tsx +++ b/apps/yaak-client/components/core/Confirm.tsx @@ -1,9 +1,8 @@ import type { Color } from "@yaakapp-internal/plugins"; -import { HStack } from "@yaakapp-internal/ui"; import type { FormEvent } from "react"; -import { useState } from "react"; +import { useId, useState } from "react"; import { CopyIconButton } from "../CopyIconButton"; -import { Button } from "./Button"; +import { DialogFooter } from "./Dialog"; import { PlainInput } from "./PlainInput"; export interface ConfirmProps { @@ -22,6 +21,7 @@ export function Confirm({ color = "primary", }: ConfirmProps) { const [confirm, setConfirm] = useState(""); + const formId = useId(); const handleHide = () => { onResult(false); onHide(); @@ -38,7 +38,7 @@ export function Confirm({ }; return ( -
+ {requireTyping && ( )} - - - - + ); } diff --git a/apps/yaak-client/components/core/DetailsBanner.tsx b/apps/yaak-client/components/core/DetailsBanner.tsx index 1cc49f729..7d4a4b89b 100644 --- a/apps/yaak-client/components/core/DetailsBanner.tsx +++ b/apps/yaak-client/components/core/DetailsBanner.tsx @@ -1,60 +1,27 @@ -import classNames from "classnames"; -import { atom, useAtom } from "jotai"; -import type { HTMLAttributes, ReactNode } from "react"; -import { useMemo } from "react"; +import { + DetailsBanner as BaseDetailsBanner, + type DetailsBannerProps, + type StoredValue, +} from "@yaakapp-internal/ui"; +import { atom, useAtom, type WritableAtom } from "jotai"; +import { type SetStateAction, useMemo } from "react"; import { atomWithKVStorage } from "../../lib/atoms/atomWithKVStorage"; -import type { BannerProps } from "@yaakapp-internal/ui"; -import { Banner } from "@yaakapp-internal/ui"; -interface Props extends HTMLAttributes { - summary: ReactNode; - color?: BannerProps["color"]; - defaultOpen?: boolean; - storageKey?: string; +export function DetailsBanner(props: Omit) { + return ; } -export function DetailsBanner({ - className, - color, - summary, - children, - defaultOpen, - storageKey, - ...extraProps -}: Props) { - // oxlint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute the atom when storageKey changes - const openAtom = useMemo( +export function useDetailsBannerStoredValue( + key: string | string[] | null, + fallback: T, +) { + // oxlint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute the atom when the key changes + const valueAtom = useMemo], void>>( () => - storageKey - ? atomWithKVStorage(["details_banner", storageKey], defaultOpen ?? false) - : atom(defaultOpen ?? false), - [storageKey], - ); - - const [isOpen, setIsOpen] = useAtom(openAtom); - - const handleToggle = (e: React.SyntheticEvent) => { - if (storageKey) { - setIsOpen(e.currentTarget.open); - } - }; - - return ( - -
- -
- {summary} -
-
{children}
-
-
+ key != null + ? atomWithKVStorage(["details_banner", ...[key].flat()], fallback) + : atom(fallback), + [key], ); + return useAtom(valueAtom); } diff --git a/apps/yaak-client/components/core/Dialog.tsx b/apps/yaak-client/components/core/Dialog.tsx index c7962a1e0..00076ec06 100644 --- a/apps/yaak-client/components/core/Dialog.tsx +++ b/apps/yaak-client/components/core/Dialog.tsx @@ -1,131 +1,2 @@ -import type { DialogSize } from "@yaakapp-internal/plugins"; -import { Heading, Overlay } from "@yaakapp-internal/ui"; -import classNames from "classnames"; -import * as m from "motion/react-m"; -import type { ReactNode } from "react"; -import { useMemo } from "react"; -import { IconButton } from "./IconButton"; - -export interface DialogProps { - children: ReactNode; - open: boolean; - onClose?: () => void; - /** 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; - vAlign?: "top" | "center"; -} - -export function Dialog({ - children, - className, - size = "full", - open, - onClose, - disableClose, - title, - description, - hideX, - noPadding, - noScroll, - vAlign = "center", -}: DialogProps) { - const titleId = useMemo(() => Math.random().toString(36).slice(2), []); - const descriptionId = useMemo( - () => (description ? Math.random().toString(36).slice(2) : undefined), - [description], - ); - - return ( - -
{ - // 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") { - if (!disableClose) onClose?.(); - e.stopPropagation(); - e.preventDefault(); - } - }} - > - - {title ? ( - - {title} - - ) : ( - - )} - - {description ? ( -
- {description} -
- ) : ( - - )} - -
- {children} -
- - {/*Put close at the end so that it's the last thing to be tabbed to*/} - {!disableClose && !hideX && ( -
- -
- )} -
-
-
- ); -} +export type { DialogFooterAction, DialogProps } from "@yaakapp-internal/ui"; +export { Dialog, DialogFooter } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/core/Dropdown.tsx b/apps/yaak-client/components/core/Dropdown.tsx index 8b3b94e98..baa52d220 100644 --- a/apps/yaak-client/components/core/Dropdown.tsx +++ b/apps/yaak-client/components/core/Dropdown.tsx @@ -1,1069 +1,10 @@ -import { HStack, Icon, type IconProps, LoadingIcon, Overlay, VStack } from "@yaakapp-internal/ui"; -import classNames from "classnames"; -import { atom } from "jotai"; -import * as m from "motion/react-m"; -import type { - CSSProperties, - HTMLAttributes, - MouseEvent, - ReactElement, - FocusEvent as ReactFocusEvent, - KeyboardEvent as ReactKeyboardEvent, - ReactNode, - RefObject, - SetStateAction, -} from "react"; -import { - Children, - cloneElement, - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, -} from "react"; -import { useKey, useWindowSize } from "react-use"; -import { useClickOutside } from "../../hooks/useClickOutside"; -import type { HotkeyAction } from "../../hooks/useHotKey"; -import { useHotKey } from "../../hooks/useHotKey"; -import { useStateWithDeps } from "../../hooks/useStateWithDeps"; -import { generateId } from "../../lib/generateId"; -import { getNodeText } from "../../lib/getNodeText"; -import { jotaiStore } from "../../lib/jotai"; -import { fireAndForget } from "../../lib/fireAndForget"; -import { ErrorBoundary } from "../ErrorBoundary"; -import { Button } from "./Button"; -import { Hotkey } from "./Hotkey"; -import { IconButton } from "./IconButton"; -import type { SeparatorAction } from "./Separator"; -import { Separator } from "./Separator"; - -export type DropdownItemSeparator = { - type: "separator"; - label?: ReactNode; - hidden?: boolean; - /** A control shown beside the label, eg. revealing the labelled file on disk. */ - action?: SeparatorAction; -}; - -export type DropdownItemContent = { - type: "content"; - label?: ReactNode; - hidden?: boolean; -}; - -export type DropdownItemDefault = { - type?: "default"; - label: ReactNode; - hotKeyAction?: HotkeyAction; - hotKeyLabelOnly?: boolean; - color?: "default" | "primary" | "danger" | "info" | "warning" | "notice" | "success"; - disabled?: boolean; - hidden?: boolean; - leftSlot?: ReactNode; - rightSlot?: ReactNode; - waitForOnSelect?: boolean; - keepOpenOnSelect?: boolean; - onSelect?: () => void | Promise; - submenu?: DropdownItem[]; - /** If true, submenu opens on click instead of hover */ - submenuOpenOnClick?: boolean; - /** - * How the submenu opens. "row" (default) opens it from the row itself (hover, or click - * with submenuOpenOnClick). "button" keeps the row selectable via onSelect and renders - * a dedicated button on the right that opens the submenu. - */ - submenuTrigger?: "row" | "button"; - icon?: IconProps["icon"]; -}; - -export type DropdownItem = DropdownItemDefault | DropdownItemSeparator | DropdownItemContent; - -export interface DropdownProps { - children: ReactElement>; - items: DropdownItem[]; - fullWidth?: boolean; - hotKeyAction?: HotkeyAction; - onOpen?: () => void; -} - -export interface DropdownRef { - isOpen: boolean; - open: (index?: number) => void; - toggle: () => void; - close?: () => void; - next?: (incrBy?: number) => void; - prev?: (incrBy?: number) => void; - select?: () => void; -} - -// Every dropdown gets a unique ID and we use this global atom to ensure -// only one dropdown can be open at a time. -// TODO: Also make ContextMenu use this -const openAtom = atom(null); - -export const Dropdown = forwardRef(function Dropdown( - { children, items, hotKeyAction, fullWidth, onOpen }: DropdownProps, - ref, -) { - const id = useRef(generateId()); - const [isOpen, setIsOpen] = useState(false); - - useEffect(() => { - return jotaiStore.sub(openAtom, () => { - const globalOpenId = jotaiStore.get(openAtom); - const newIsOpen = globalOpenId === id.current; - if (newIsOpen !== isOpen) { - setIsOpen(newIsOpen); - } - }); - }, [isOpen]); - - // const [isOpen, _setIsOpen] = useState(false); - const [defaultSelectedIndex, setDefaultSelectedIndex] = useState(null); - const buttonRef = useRef(null); - const menuRef = useRef>(null); - - const handleSetIsOpen = useCallback( - (o: SetStateAction) => { - jotaiStore.set(openAtom, (prevId) => { - const prevIsOpen = prevId === id.current; - const newIsOpen = typeof o === "function" ? o(prevIsOpen) : o; - // Persist background color of button until we close the dropdown - if (newIsOpen) { - onOpen?.(); - if (buttonRef.current) { - buttonRef.current.style.backgroundColor = window - .getComputedStyle(buttonRef.current) - .getPropertyValue("background-color"); - } - } - return newIsOpen ? id.current : null; // Set global atom to current ID to signify open state - }); - }, - [onOpen], - ); - - // Because a different dropdown can cause ours to close, a useEffect([isOpen]) is the only method - // we have of detecting the dropdown closed, to do cleanup. - useEffect(() => { - if (!isOpen) { - // Clear persisted BG - if (buttonRef.current) buttonRef.current.style.backgroundColor = ""; - // Set to different value when opened and closed to force it to update. This is to force - // to reset its selected-index state, which it does when this prop changes - setDefaultSelectedIndex(null); - } - }, [isOpen]); - - // Pull into variable so linter forces us to add it as a hook dep to useImperativeHandle. If we don't, - // the ref will not update when menuRef updates, causing stale callback state to be used. - const menuRefCurrent = menuRef.current; - - useImperativeHandle( - ref, - () => ({ - ...menuRefCurrent, - isOpen: isOpen, - toggle() { - if (!isOpen) this.open(); - else this.close(); - }, - open(index?: number) { - handleSetIsOpen(true); - setDefaultSelectedIndex(index ?? -1); - }, - close() { - handleSetIsOpen(false); - }, - }), - [isOpen, handleSetIsOpen, menuRefCurrent], - ); - - useHotKey(hotKeyAction ?? null, () => { - setDefaultSelectedIndex(0); - handleSetIsOpen(true); - }); - - const child = useMemo(() => { - const existingChild = Children.only(children); - const originalOnClick = existingChild.props?.onClick; - const props: HTMLAttributes & { ref: RefObject } = - { - ...existingChild.props, - ref: buttonRef, - "aria-haspopup": "true", - onClick: (e: MouseEvent) => { - // Call original onClick first if it exists - originalOnClick?.(e); - - // Only toggle dropdown if event wasn't prevented - if (!e.defaultPrevented) { - e.preventDefault(); - e.stopPropagation(); - handleSetIsOpen((o) => !o); // Toggle dropdown - } - }, - }; - return cloneElement(existingChild, props); - }, [children, handleSetIsOpen]); - - useEffect(() => { - buttonRef.current?.setAttribute("aria-expanded", isOpen.toString()); - }, [isOpen]); - - const windowSize = useWindowSize(); - const triggerRect = useMemo(() => { - if (!windowSize) return null; // No-op to TS happy with this dep - if (!isOpen) return null; - return buttonRef.current?.getBoundingClientRect(); - }, [isOpen, windowSize]); - - return ( - <> - {child} - - handleSetIsOpen(false)} - isOpen={isOpen} - /> - - - ); -}); - -export interface ContextMenuProps { - triggerPosition: { x: number; y: number } | null; - /** - * The box the menu belongs to, when the trigger has one. - * - * Placement aligns to the trigger's left or right edge depending on the room beside it, so a - * menu opened from an element wants its real rect. Without one the position is treated as a - * zero-width point, which is right for a menu opened at the cursor and wrong for one opened - * from a button. - */ - triggerRect?: Pick; - /** - * The element the menu belongs to, so a click on it doesn't count as a click outside. - * - * Without this the trigger gets both: the outside-click handler closes the menu on mousedown, - * then the trigger's own click opens it again, and pressing it looks like it does nothing. - */ - triggerRef?: RefObject; - className?: string; - items: DropdownProps["items"]; - onClose: () => void; -} - -export const ContextMenu = forwardRef(function ContextMenu( - { triggerPosition, triggerRect, triggerRef, className, items, onClose }, - ref, -) { - const triggerShape = useMemo( - () => - triggerRect ?? { - top: triggerPosition?.y ?? 0, - bottom: triggerPosition?.y ?? 0, - left: triggerPosition?.x ?? 0, - right: triggerPosition?.x ?? 0, - }, - [triggerPosition, triggerRect], - ); - - if (triggerPosition == null) return null; - - return ( - - ); -}); - -interface MenuProps { - className?: string; - defaultSelectedIndex: number | null; - triggerShape: Pick | null; - onClose: () => void; - onCloseAll?: () => void; - showTriangle?: boolean; - fullWidth?: boolean; - isOpen: boolean; - items: DropdownItem[]; - // Any element, not just a button: a menu can be opened from anything, and this is only ever - // used to ask whether a click landed on the trigger - triggerRef?: RefObject; - isSubmenu?: boolean; -} - -const Menu = forwardRef, MenuProps>( - ( - { - className, - isOpen, - items, - fullWidth, - onClose, - onCloseAll, - triggerShape, - defaultSelectedIndex, - showTriangle, - triggerRef, - isSubmenu, - }: MenuProps, - ref, - ) => { - const [selectedIndex, setSelectedIndex] = useStateWithDeps( - defaultSelectedIndex ?? -1, - [defaultSelectedIndex], - ); - - const [filter, setFilter] = useState(""); - - // Clear filter when menu opens - useEffect(() => { - if (isOpen) { - setFilter(""); - } - }, [isOpen]); - - const [activeSubmenu, setActiveSubmenu] = useState<{ - item: DropdownItemDefault; - parent: HTMLButtonElement; - viaKeyboard?: boolean; - } | null>(null); - - const mousePosition = useRef({ x: 0, y: 0 }); - const submenuTimeoutRef = useRef(null); - const submenuRef = useRef(null); - - // HACK: Use a ref to track selectedIndex so our closure functions (eg. select()) can - // have access to the latest value. - const selectedIndexRef = useRef(selectedIndex); - useEffect(() => { - selectedIndexRef.current = selectedIndex; - }, [selectedIndex]); - - const handleClose = useCallback(() => { - onClose(); - setActiveSubmenu(null); - }, [onClose]); - - // Close the entire menu hierarchy (used when selecting an item) - const handleCloseAll = useCallback(() => { - if (onCloseAll) { - onCloseAll(); - } else { - handleClose(); - } - }, [onCloseAll, handleClose]); - - // Handle type-ahead filtering (only for the deepest open menu) - const handleMenuKeyDown = (e: ReactKeyboardEvent) => { - // Skip if this menu has a submenu open - let the submenu handle typing - if (activeSubmenu) return; - - const isCharacter = e.key.length === 1; - const isSpecial = e.ctrlKey || e.metaKey || e.altKey; - if (isCharacter && !isSpecial) { - e.preventDefault(); - setFilter((f) => f + e.key); - setSelectedIndex(0); - } else if (e.key === "Backspace" && !isSpecial) { - e.preventDefault(); - setFilter((f) => f.slice(0, -1)); - } - }; - - useKey( - "Escape", - () => { - if (!isOpen) return; - if (activeSubmenu) setActiveSubmenu(null); - else if (filter !== "") setFilter(""); - else handleClose(); - }, - {}, - [isOpen, filter, setFilter, handleClose, activeSubmenu], - ); - - const handlePrev = useCallback( - (incrBy = 1) => { - setSelectedIndex((currIndex) => { - let nextIndex = (currIndex ?? 0) - incrBy; - const maxTries = items.length; - for (let i = 0; i < maxTries; i++) { - if (items[nextIndex]?.hidden || items[nextIndex]?.type === "separator") { - nextIndex--; - } else if (nextIndex < 0) { - nextIndex = items.length - 1; - } else { - break; - } - } - return nextIndex; - }); - }, - [items, setSelectedIndex], - ); - - const handleNext = useCallback( - (incrBy = 1) => { - setSelectedIndex((currIndex) => { - let nextIndex = (currIndex ?? -1) + incrBy; - const maxTries = items.length; - for (let i = 0; i < maxTries; i++) { - if (items[nextIndex]?.hidden || items[nextIndex]?.type === "separator") { - nextIndex++; - } else if (nextIndex >= items.length) { - nextIndex = 0; - } else { - break; - } - } - return nextIndex; - }); - }, - [items, setSelectedIndex], - ); - - // Ensure selection is on a valid item (not hidden/separator/content) - useEffect(() => { - const item = items[selectedIndex ?? -1]; - if (item?.hidden || item?.type === "separator" || item?.type === "content") { - handleNext(); - } - }, [selectedIndex, items, handleNext]); - - useKey( - "ArrowUp", - (e) => { - if (!isOpen || activeSubmenu) return; - e.preventDefault(); - handlePrev(); - }, - {}, - [isOpen, activeSubmenu], - ); - - useKey( - "ArrowDown", - (e) => { - if (!isOpen || activeSubmenu) return; - e.preventDefault(); - handleNext(); - }, - {}, - [isOpen, activeSubmenu], - ); - - useKey( - "ArrowLeft", - (e) => { - if (!isOpen) return; - // Only handle if this menu doesn't have an open submenu - // (let the deepest submenu handle the key first) - if (activeSubmenu) return; - // If this is a submenu, ArrowLeft closes it and returns to parent - if (isSubmenu) { - e.preventDefault(); - onClose(); - } - }, - {}, - [isOpen, isSubmenu, activeSubmenu, onClose], - ); - - const handleSelect = useCallback( - async (item: DropdownItem, parentEl?: HTMLButtonElement) => { - // Handle click-to-open submenu - if ("submenu" in item && item.submenu && item.submenuOpenOnClick && parentEl) { - setActiveSubmenu({ item, parent: parentEl }); - return; - } - - if (!("onSelect" in item) || !item.onSelect) return; - setSelectedIndex(null); - - const promise = item.onSelect(); - if (item.waitForOnSelect) { - try { - await promise; - } catch { - // Nothing - } - } - - if (!item.keepOpenOnSelect) { - handleCloseAll(); - } else if (isSubmenu) { - // Keep the parent menu open, but close this submenu — its items may no - // longer describe the row after the action (e.g. Pin → Unpin, Remove) - handleClose(); - } - }, - [handleCloseAll, handleClose, isSubmenu, setSelectedIndex], - ); - - useImperativeHandle(ref, () => { - return { - close: handleClose, - prev: handlePrev, - next: handleNext, - select: async () => { - const item = items[selectedIndexRef.current ?? -1] ?? null; - if (!item) return; - await handleSelect(item); - }, - }; - }, [handleClose, handleNext, handlePrev, handleSelect, items]); - - const styles = useMemo<{ - container: CSSProperties; - menu: CSSProperties; - triangle: CSSProperties; - upsideDown: boolean; - }>(() => { - if (triggerShape == null) return { container: {}, triangle: {}, menu: {}, upsideDown: false }; - - if (isSubmenu) { - const parentRect = triggerShape; - const docRect = document.documentElement.getBoundingClientRect(); - const spaceRight = docRect.width - parentRect.right; - const spaceBelow = docRect.height - parentRect.top; - const spaceAbove = parentRect.bottom; - const openLeft = spaceRight < 200; // Heuristic to open on left if not enough space on right - // Estimate submenu height (items * ~28px + padding), flip if not enough space below - const estimatedHeight = items.length * 28 + 20; - const openUpward = spaceBelow < estimatedHeight && spaceAbove > spaceBelow; - - return { - upsideDown: openUpward, - container: { - top: openUpward ? undefined : parentRect.top, - bottom: openUpward ? docRect.height - parentRect.bottom : undefined, - left: openLeft ? undefined : parentRect.right, - right: openLeft ? docRect.width - parentRect.left : undefined, - }, - menu: { - maxHeight: `${(openUpward ? spaceAbove : spaceBelow) - 20}px`, - }, - triangle: {}, // No triangle for submenus - }; - } - - const menuMarginY = 5; - const docRect = document.documentElement.getBoundingClientRect(); - const width = triggerShape.right - triggerShape.left; - const heightAbove = triggerShape.top; - const heightBelow = docRect.height - triggerShape.bottom; - const horizontalSpaceRemaining = docRect.width - triggerShape.left; - const top = triggerShape.bottom; - const onRight = horizontalSpaceRemaining < 300; - const upsideDown = heightBelow < heightAbove && heightBelow < items.length * 25 + 20 + 200; - const triggerWidth = triggerShape.right - triggerShape.left; - return { - upsideDown, - container: { - top: !upsideDown ? top + menuMarginY : undefined, - bottom: upsideDown - ? docRect.height - top - (triggerShape.top - triggerShape.bottom) + menuMarginY - : undefined, - right: onRight ? docRect.width - triggerShape.right : undefined, - left: !onRight ? triggerShape.left : undefined, - minWidth: fullWidth ? triggerWidth : undefined, - maxWidth: "40rem", - }, - triangle: { - width: "0.4rem", - height: "0.4rem", - ...(onRight - ? { right: width / 2, marginRight: "-0.2rem" } - : { left: width / 2, marginLeft: "-0.2rem" }), - ...(upsideDown - ? { bottom: "-0.2rem", rotate: "225deg" } - : { top: "-0.2rem", rotate: "45deg" }), - }, - menu: { - maxHeight: `${(upsideDown ? heightAbove : heightBelow) - 15}px`, - }, - }; - }, [fullWidth, items.length, triggerShape, isSubmenu]); - - const filteredItems = useMemo( - () => items.filter((i) => getNodeText(i.label).toLowerCase().includes(filter.toLowerCase())), - [items, filter], - ); - - const handleFocus = useCallback( - (i: DropdownItem) => { - const index = filteredItems.indexOf(i) ?? null; - setSelectedIndex(index); - }, - [filteredItems, setSelectedIndex], - ); - - useKey( - "ArrowRight", - (e) => { - if (!isOpen || activeSubmenu) return; - const item = filteredItems[selectedIndex ?? -1]; - if (item?.type !== "separator" && item?.type !== "content" && item?.submenu) { - e.preventDefault(); - const parent = document.activeElement as HTMLButtonElement; - if (parent) { - setActiveSubmenu({ item, parent, viaKeyboard: true }); - } - } - }, - {}, - [isOpen, activeSubmenu, filteredItems, selectedIndex], - ); - - useKey( - "Enter", - (e) => { - if (!isOpen || activeSubmenu) return; - const item = filteredItems[selectedIndex ?? -1]; - if (!item || item.type === "separator" || item.type === "content") return; - e.preventDefault(); - if (item.submenu && item.submenuTrigger !== "button") { - const parent = document.activeElement as HTMLButtonElement; - if (parent) { - setActiveSubmenu({ item, parent, viaKeyboard: true }); - } - } else if (item.onSelect) { - fireAndForget(handleSelect(item)); - } - }, - {}, - [isOpen, activeSubmenu, filteredItems, selectedIndex, handleSelect], - ); - - const handleItemHover = useCallback( - (item: DropdownItemDefault, parent: HTMLButtonElement) => { - if (submenuTimeoutRef.current) { - clearTimeout(submenuTimeoutRef.current); - } - - if (item.submenu && !item.submenuOpenOnClick && item.submenuTrigger !== "button") { - setActiveSubmenu({ item, parent }); - } else if (activeSubmenu && activeSubmenu.item !== item) { - // Hovering the row that owns the open submenu must not dismiss it — the - // pointer travels across the row on its way to a button-triggered submenu - submenuTimeoutRef.current = window.setTimeout(() => { - const submenuEl = submenuRef.current; - if (!submenuEl || !activeSubmenu) { - setActiveSubmenu(null); - return; - } - - const { parent } = activeSubmenu; - const parentRect = parent.getBoundingClientRect(); - const submenuRect = submenuEl.getBoundingClientRect(); - const mouse = mousePosition.current; - - if ( - mouse.x >= submenuRect.left && - mouse.x <= submenuRect.right && - mouse.y >= submenuRect.top && - mouse.y <= submenuRect.bottom - ) { - return; - } - - const tolerance = 5; - const p1 = { x: parentRect.right, y: parentRect.top - tolerance }; - const p2 = { x: parentRect.right, y: parentRect.bottom + tolerance }; - const p3 = { x: submenuRect.left, y: submenuRect.top - tolerance }; - const p4 = { x: submenuRect.left, y: submenuRect.bottom + tolerance }; - - const inTriangle = - isPointInTriangle(mouse, p1, p2, p4) || isPointInTriangle(mouse, p1, p3, p4); - - if (!inTriangle) { - setActiveSubmenu(null); - } - }, 100); - } - }, - [activeSubmenu], - ); - - const menuRef = useRef(null); - useClickOutside(menuRef, handleClose, triggerRef); - - // Keep focus on menu container when filtering leaves no items - useEffect(() => { - if (filteredItems.length === 0 && filter && menuRef.current) { - menuRef.current.focus(); - } - }, [filteredItems.length, filter]); - - const submenuTriggerShape = useMemo(() => { - if (!activeSubmenu) return null; - const rect = activeSubmenu.parent.getBoundingClientRect(); - return { - top: rect.top, - bottom: rect.bottom, - left: rect.left, - right: rect.right, - }; - }, [activeSubmenu]); - - const handleMouseMove = (event: React.MouseEvent) => { - mousePosition.current = { x: event.clientX, y: event.clientY }; - }; - - const menuContent = ( - { - // Prevent showing any ancestor context menus - e.stopPropagation(); - e.preventDefault(); - }} - initial={{ opacity: 0, y: (styles.upsideDown ? 1 : -1) * 5, scale: 0.98 }} - animate={{ opacity: 1, y: 0, scale: 1 }} - role="menu" - aria-orientation="vertical" - dir="ltr" - style={styles.container} - className={classNames( - className, - "x-theme-menu", - "outline-hidden my-1 pointer-events-auto z-40", - "fixed", - )} - > - {showTriangle && !isSubmenu && ( - - )} - - {filter && ( - - -
{filter}
-
- )} - {filteredItems.length === 0 && ( - No matches - )} - {filteredItems.map((item, i) => { - if (item.hidden) { - return null; - } - if (item.type === "separator") { - return ( - - {item.label} - - ); - } - if (item.type === "content") { - return ( - // oxlint-disable-next-line no-array-index-key -- index is fine -
- {item.label} -
- ); - } - const isParentOfActiveSubmenu = activeSubmenu?.item === item; - return ( - setActiveSubmenu({ item, parent: el })} - // oxlint-disable-next-line no-array-index-key -- It's fine - key={i} - item={item} - /> - ); - })} -
- {activeSubmenu && ( -
{ - if (submenuTimeoutRef.current) { - clearTimeout(submenuTimeoutRef.current); - } - }} - > - setActiveSubmenu(null)} - onCloseAll={handleCloseAll} - triggerShape={submenuTriggerShape} - /> -
- )} -
- ); - - // Hotkeys must be rendered even when menu is closed (so they work globally) - const hotKeyElements = items.map( - (item, i) => - item.type !== "separator" && - item.type !== "content" && - !item.hotKeyLabelOnly && - item.hotKeyAction && ( - - ), - ); - - if (!isOpen) { - return <>{hotKeyElements}; - } - - if (isSubmenu) { - return menuContent; - } - - return ( - <> - {hotKeyElements} - - {menuContent} - - - ); - }, -); - -interface MenuItemProps { - className?: string; - item: DropdownItemDefault; - onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise; - onFocus: (item: DropdownItemDefault) => void; - onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void; - onOpenSubmenu: (item: DropdownItemDefault, el: HTMLButtonElement) => void; - focused: boolean; - isParentOfActiveSubmenu?: boolean; -} - -function MenuItem({ - className, - focused, - onFocus, - onHover, - item, - onSelect, - onOpenSubmenu, - isParentOfActiveSubmenu, - ...props -}: MenuItemProps) { - const [isLoading, setIsLoading] = useState(false); - const handleClick = useCallback(async () => { - if (item.waitForOnSelect) setIsLoading(true); - await onSelect?.(item, buttonRef.current ?? undefined); - if (item.waitForOnSelect) setIsLoading(false); - }, [item, onSelect]); - - const handleFocus = useCallback( - (e: ReactFocusEvent) => { - e.stopPropagation(); // Don't trigger focus on any parents - return onFocus?.(item); - }, - [item, onFocus], - ); - - const buttonRef = useRef(null); - const initRef = useCallback( - (el: HTMLButtonElement | null) => { - buttonRef.current = el; - if (el === null) return; - if (focused) { - setTimeout(() => el.focus(), 0); - } - }, - [focused], - ); - - const handleMouseEnter = (e: MouseEvent) => { - onHover(item, e.currentTarget); - e.currentTarget.focus(); - }; - - const hasButtonSubmenu = item.submenu != null && item.submenuTrigger === "button"; - - const rightSlot = - item.submenu && !hasButtonSubmenu ? ( - - ) : ( - (item.rightSlot ?? ) - ); - - const button = ( - - ); - - if (!hasButtonSubmenu) { - return button; - } - - // The submenu trigger overlays the row as a sibling (not a child) because the row is - // itself a button and buttons cannot nest. Hover handling lives on this wrapper so the - // row keeps its focus highlight while the mouse is over the trigger. - return ( -
{ - const el = buttonRef.current; - if (el == null) return; - onHover(item, el); - el.focus(); - }} - onMouseLeave={() => buttonRef.current?.blur()} - > - {button} -
- { - // Prevent the trigger from stealing focus, which would unhighlight the row - e.preventDefault(); - }} - onClick={(e) => { - e.stopPropagation(); - onOpenSubmenu(item, e.currentTarget); - }} - /> -
-
- ); -} - -interface MenuItemHotKeyProps { - action: HotkeyAction | undefined; - onSelect: MenuItemProps["onSelect"]; - item: MenuItemProps["item"]; -} - -function MenuItemHotKey({ action, onSelect, item }: MenuItemHotKeyProps) { - useHotKey(action ?? null, () => onSelect(item)); - return null; -} - -function sign( - p1: { x: number; y: number }, - p2: { x: number; y: number }, - p3: { x: number; y: number }, -) { - return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y); -} - -function isPointInTriangle( - pt: { x: number; y: number }, - v1: { x: number; y: number }, - v2: { x: number; y: number }, - v3: { x: number; y: number }, -) { - const d1 = sign(pt, v1, v2); - const d2 = sign(pt, v2, v3); - const d3 = sign(pt, v3, v1); - - const has_neg = d1 < 0 || d2 < 0 || d3 < 0; - const has_pos = d1 > 0 || d2 > 0 || d3 > 0; - - return !(has_neg && has_pos); -} +export type { + ContextMenuProps, + DropdownItem, + DropdownItemContent, + DropdownItemDefault, + DropdownItemSeparator, + DropdownProps, + DropdownRef, +} from "@yaakapp-internal/ui"; +export { ContextMenu, Dropdown } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/core/Editor/DiffViewer.tsx b/apps/yaak-client/components/core/Editor/DiffViewer.tsx index f36f28ee4..889a8f992 100644 --- a/apps/yaak-client/components/core/Editor/DiffViewer.tsx +++ b/apps/yaak-client/components/core/Editor/DiffViewer.tsx @@ -5,7 +5,8 @@ import { EditorView } from "@codemirror/view"; import classNames from "classnames"; import { useEffect, useRef } from "react"; import "./DiffViewer.css"; -import { readonlyExtensions, syntaxHighlightStyle } from "./extensions"; +import { syntaxHighlightStyle } from "@yaakapp-internal/ui/editor"; +import { readonlyExtensions } from "./extensions"; interface Props { /** Original/previous version (left side) */ diff --git a/apps/yaak-client/components/core/Editor/Editor.tsx b/apps/yaak-client/components/core/Editor/Editor.tsx index fa18b8e59..fb524b336 100644 --- a/apps/yaak-client/components/core/Editor/Editor.tsx +++ b/apps/yaak-client/components/core/Editor/Editor.tsx @@ -1,147 +1,53 @@ import { startCompletion } from "@codemirror/autocomplete"; -import { defaultKeymap, historyField, indentWithTab } from "@codemirror/commands"; -import { foldState, forceParsing } from "@codemirror/language"; -import type { EditorStateConfig, Extension } from "@codemirror/state"; -import { Compartment, EditorState } from "@codemirror/state"; -import { EditorView, keymap, placeholder as placeholderExt, tooltips } from "@codemirror/view"; -import { emacs } from "@replit/codemirror-emacs"; -import { vim } from "@replit/codemirror-vim"; - -import { vscodeKeymap } from "@replit/codemirror-vscode-keymap"; -import { debounce } from "@yaakapp-internal/lib"; -import type { EditorKeymap } from "@yaakapp-internal/models"; +import type { Extension } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; import { settingsAtom } from "@yaakapp-internal/models"; import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins"; -import { HStack } from "@yaakapp-internal/ui"; -import classNames from "classnames"; +import { CodeEditor, type CodeEditorProps } from "@yaakapp-internal/ui/editor"; import type { GraphQLSchema } from "graphql"; import { useAtomValue } from "jotai"; -import type { ReactNode, RefObject } from "react"; -import { - Children, - cloneElement, - isValidElement, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, -} from "react"; +import { useCallback, useMemo, useRef } from "react"; import { activeEnvironmentAtom } from "../../../hooks/useActiveEnvironment"; import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables"; import { useEnvironmentVariables } from "../../../hooks/useEnvironmentVariables"; import { eventMatchesHotkey } from "../../../hooks/useHotKey"; import { useRequestEditor } from "../../../hooks/useRequestEditor"; import { useTemplateFunctionCompletionOptions } from "../../../hooks/useTemplateFunctions"; -import { docFingerprint } from "../../../lib/docFingerprint"; import { editEnvironment } from "../../../lib/editEnvironment"; import { tryFormatJson, tryFormatXml } from "../../../lib/formatters"; import { jotaiStore } from "../../../lib/jotai"; import { withEncryptionEnabled } from "../../../lib/setupOrConfigureEncryption"; import { TemplateFunctionDialog } from "../../TemplateFunctionDialog"; -import { IconButton } from "../IconButton"; -import "./Editor.css"; -import { - baseExtensions, - editableExtensions, - getLanguageExtension, - multiLineExtensions, - readonlyExtensions, -} from "./extensions"; +import { getLanguageExtension } from "./extensions"; import type { GenericCompletionConfig } from "./genericCompletion"; -import { singleLineExtensions } from "./singleLine"; +import { largeValues, mediaValues } from "./largeValues"; -// VSCode's Tab actions mess with the single-line editor tab actions, so remove it. -const vsCodeWithoutTab = vscodeKeymap.filter((k) => k.key !== "Tab"); - -const keymapExtensions: Record = { - vim: vim(), - emacs: emacs(), - vscode: keymap.of(vsCodeWithoutTab), - default: [], -}; - -export interface EditorProps { - actions?: ReactNode; - autoFocus?: boolean; - autoSelect?: boolean; +export type EditorProps = Omit & { autocomplete?: GenericCompletionConfig; autocompleteFunctions?: boolean; autocompleteVariables?: boolean | ((v: WrappedEnvironmentVariable) => boolean); - className?: string; - defaultValue?: string | null; - disableTabIndent?: boolean; - disabled?: boolean; extraExtensions?: Extension[] | Extension; forcedEnvironmentId?: string; - forceUpdateKey?: string | number; - format?: (v: string) => Promise; - heightMode?: "auto" | "full"; - hideGutter?: boolean; - id?: string; - language?: EditorLanguage | "pairs" | "url" | "timeline" | null; - lintExtension?: Extension; graphQLSchema?: GraphQLSchema | null; - onBlur?: () => void; - onChange?: (value: string) => void; - onFocus?: () => void; - onKeyDown?: (e: KeyboardEvent) => void; - onPaste?: (value: string) => void; - onPasteOverwrite?: (e: ClipboardEvent, value: string) => void; - placeholder?: string; - readOnly?: boolean; - singleLine?: boolean; - containerOnly?: boolean; - stateKey: string | null; - tooltipContainer?: HTMLElement; - type?: "text" | "password"; - wrapLines?: boolean; - setRef?: (view: EditorView | null) => void; -} - -const stateFields = { history: historyField, folds: foldState }; + language?: EditorLanguage | "pairs" | "url" | "timeline" | null; +}; const emptyVariables: WrappedEnvironmentVariable[] = []; -const emptyExtension: Extension = []; -export function Editor(props: EditorProps) { - return ; -} - -function EditorInner({ - actions, - autoFocus, - autoSelect, +export function Editor({ autocomplete, autocompleteFunctions, autocompleteVariables, - className, - defaultValue, - disableTabIndent, - disabled, extraExtensions, forcedEnvironmentId, - forceUpdateKey, format, - heightMode, - hideGutter, graphQLSchema, + hideGutter, language, lintExtension, - onBlur, - onChange, - onFocus, - onKeyDown, - onPaste, - onPasteOverwrite, - placeholder, readOnly, - singleLine, - containerOnly, - stateKey, - type, wrapLines, - setRef, + ...props }: EditorProps) { const settings = useAtomValue(settingsAtom); @@ -158,20 +64,10 @@ function EditorInner({ wrapLines = settings.editorSoftWrap; } - if (disabled) { + if (props.disabled) { readOnly = true; } - if ( - singleLine || - language == null || - language === "text" || - language === "url" || - language === "pairs" - ) { - disableTabIndent = true; - } - if (format == null && !readOnly) { format = language === "json" @@ -181,130 +77,22 @@ function EditorInner({ : undefined; } - const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null); - - // Use ref so we can update the handler without re-initializing the editor - const handleChange = useRef(onChange); - useEffect(() => { - handleChange.current = onChange; - }, [onChange]); - - // Use ref so we can update the handler without re-initializing the editor - const handlePaste = useRef(onPaste); - useEffect(() => { - handlePaste.current = onPaste; - }, [onPaste]); - - // Use ref so we can update the handler without re-initializing the editor - const handlePasteOverwrite = useRef(onPasteOverwrite); - useEffect(() => { - handlePasteOverwrite.current = onPasteOverwrite; - }, [onPasteOverwrite]); - - // Use ref so we can update the handler without re-initializing the editor - const handleFocus = useRef(onFocus); - useEffect(() => { - handleFocus.current = onFocus; - }, [onFocus]); - - // Use ref so we can update the handler without re-initializing the editor - const handleBlur = useRef(onBlur); - useEffect(() => { - handleBlur.current = onBlur; - }, [onBlur]); - - // Use ref so we can update the handler without re-initializing the editor - const handleKeyDown = useRef(onKeyDown); - useEffect(() => { - handleKeyDown.current = onKeyDown; - }, [onKeyDown]); - - // Update placeholder - const placeholderCompartment = useRef(new Compartment()); - useEffect( - function configurePlaceholder() { - if (cm.current === null) return; - const ext = placeholderExt(placeholderElFromText(placeholder)); - const effects = placeholderCompartment.current.reconfigure(ext); - cm.current?.view.dispatch({ effects }); + // Template dialogs are opened against the editor the tag was clicked in + const viewRef = useRef(null); + const { setRef } = props; + const handleSetRef = useCallback( + (view: EditorView | null) => { + viewRef.current = view; + setRef?.(view); }, - [placeholder], - ); - - // Update vim - const keymapCompartment = useRef(new Compartment()); - useEffect( - function configureKeymap() { - if (cm.current === null) return; - const current = keymapCompartment.current.get(cm.current.view.state) ?? []; - // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary - if (settings.editorKeymap === "default" && current === keymapExtensions.default) return; // Nothing to do - if (settings.editorKeymap === "vim" && current === keymapExtensions.vim) return; // Nothing to do - if (settings.editorKeymap === "vscode" && current === keymapExtensions.vscode) return; // Nothing to do - if (settings.editorKeymap === "emacs" && current === keymapExtensions.emacs) return; // Nothing to do - - const ext = keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default; - const effects = keymapCompartment.current.reconfigure(ext); - cm.current.view.dispatch({ effects }); - }, - [settings.editorKeymap], - ); - - // Update wrap lines - const wrapLinesCompartment = useRef(new Compartment()); - useEffect( - function configureWrapLines() { - if (cm.current === null) return; - const current = wrapLinesCompartment.current.get(cm.current.view.state) ?? emptyExtension; - // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary - if (wrapLines && current !== emptyExtension) return; // Nothing to do - if (!wrapLines && current === emptyExtension) return; // Nothing to do - - const ext = wrapLines ? EditorView.lineWrapping : emptyExtension; - const effects = wrapLinesCompartment.current.reconfigure(ext); - cm.current?.view.dispatch({ effects }); - }, - [wrapLines], - ); - - // Update tab indent - const tabIndentCompartment = useRef(new Compartment()); - useEffect( - function configureTabIndent() { - if (cm.current === null) return; - const current = tabIndentCompartment.current.get(cm.current.view.state) ?? emptyExtension; - // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary - if (disableTabIndent && current !== emptyExtension) return; // Nothing to do - if (!disableTabIndent && current === emptyExtension) return; // Nothing to do - - const ext = !disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension; - const effects = tabIndentCompartment.current.reconfigure(ext); - cm.current?.view.dispatch({ effects }); - }, - [disableTabIndent], - ); - - // Update read-only - const readOnlyCompartment = useRef(new Compartment()); - useEffect( - function configureReadOnly() { - if (cm.current === null) return; - const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension; - const next = readOnly ? readonlyExtensions : editableExtensions; - // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary - if (current === next) return; - - const effects = readOnlyCompartment.current.reconfigure(next); - cm.current?.view.dispatch({ effects }); - }, - [readOnly], + [setRef], ); const onClickFunction = useCallback( async (fn: TemplateFunction, tagValue: string, startPos: number) => { const show = () => { - if (cm.current === null) return; - TemplateFunctionDialog.show(fn, tagValue, startPos, cm.current.view); + if (viewRef.current === null) return; + TemplateFunctionDialog.show(fn, tagValue, startPos, viewRef.current); }; if (fn.name === "secure") { @@ -344,427 +132,64 @@ function EditorInner({ !!autocompleteFunctions, ); - // Update the language extension when the language changes - // oxlint-disable-next-line react-hooks/exhaustive-deps -- intentionally limited deps - useEffect(() => { - if (cm.current === null) return; - const { view, languageCompartment } = cm.current; - const ext = getLanguageExtension({ - useTemplating, + const languageExtension = useMemo( + () => + getLanguageExtension({ + useTemplating, + language, + lintExtension, + hideGutter, + environmentVariables, + autocomplete, + completionOptions, + onClickVariable, + onClickMissingVariable, + onClickPathParameter, + graphQLSchema: graphQLSchema ?? null, + }), + [ language, lintExtension, - hideGutter, - environmentVariables, autocomplete, - completionOptions, + environmentVariables, onClickVariable, onClickMissingVariable, onClickPathParameter, - graphQLSchema: graphQLSchema ?? null, - }); - view.dispatch({ effects: languageCompartment.reconfigure(ext) }); - }, [ - language, - lintExtension, - autocomplete, - environmentVariables, - onClickFunction, - onClickVariable, - onClickMissingVariable, - onClickPathParameter, - completionOptions, - useTemplating, - graphQLSchema, - hideGutter, - ]); - - // Initialize the editor when ref mounts - // oxlint-disable-next-line react-hooks/exhaustive-deps -- only reinitialize when necessary - const initEditorRef = useCallback( - function initEditorRef(container: HTMLDivElement | null) { - if (container === null) { - flushCachedEditorState(stateKey); - cm.current?.view.destroy(); - cm.current = null; - return; - } - - try { - const languageCompartment = new Compartment(); - const langExt = getLanguageExtension({ - useTemplating, - language, - lintExtension, - completionOptions, - autocomplete, - environmentVariables, - onClickVariable, - onClickMissingVariable, - onClickPathParameter, - graphQLSchema: graphQLSchema ?? null, - }); - const extensions = [ - languageCompartment.of(langExt), - placeholderCompartment.current.of(placeholderExt(placeholderElFromText(placeholder))), - wrapLinesCompartment.current.of(wrapLines ? EditorView.lineWrapping : emptyExtension), - tabIndentCompartment.current.of( - !disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension, - ), - keymapCompartment.current.of( - keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default, - ), - readOnlyCompartment.current.of(readOnly ? readonlyExtensions : editableExtensions), - ...getExtensions({ - container, - singleLine, - hideGutter, - stateKey, - onChange: handleChange, - onPaste: handlePaste, - onPasteOverwrite: handlePasteOverwrite, - onFocus: handleFocus, - onBlur: handleBlur, - onKeyDown: handleKeyDown, - }), - ...(Array.isArray(extraExtensions) - ? extraExtensions - : extraExtensions - ? [extraExtensions] - : []), - ]; - - const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey, !!readOnly); - - const doc = `${defaultValue ?? ""}`; - const config: EditorStateConfig = { extensions, doc }; - - const state = cachedJsonState - ? EditorState.fromJSON(cachedJsonState, config, stateFields) - : EditorState.create(config); - - const view = new EditorView({ state, parent: container }); - - // For large documents, the parser may parse the max number of lines and fail to add - // things like fold markers because of it. - // This forces it to parse more but keeps the timeout to the default of 100 ms. - forceParsing(view, 9e6, 100); - - cm.current = { view, languageCompartment }; - if (autoFocus) { - view.focus(); - } - if (autoSelect) { - view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } }); - } - setRef?.(view); - } catch (e) { - console.log("Failed to initialize Codemirror", e); - } - }, - [forceUpdateKey], + completionOptions, + useTemplating, + graphQLSchema, + hideGutter, + ], ); - // For read-only mode, update content when `defaultValue` changes - useEffect( - function updateReadOnlyEditor() { - if (readOnly && cm.current?.view != null) { - updateContents(cm.current.view, defaultValue || ""); - } - }, - [defaultValue, readOnly], - ); - - // Force input to update when receiving change and not in focus - useLayoutEffect( - function updateNonFocusedEditor() { - const notFocused = !cm.current?.view.hasFocus; - if (notFocused && cm.current != null) { - updateContents(cm.current.view, defaultValue || ""); - } - }, - [defaultValue], - ); - - // Add bg classes to actions, so they appear over the text - const decoratedActions = useMemo(() => { - const results = []; - const actionClassName = classNames( - "bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:opacity-100! shadow", - ); - - if (format) { - results.push( - { - if (cm.current === null) return; - const { doc } = cm.current.view.state; - const formatted = await format(doc.toString()); - // Update editor and blur because the cursor will reset anyway - cm.current.view.dispatch({ - changes: { from: 0, to: doc.length, insert: formatted }, - }); - cm.current.view.contentDOM.blur(); - // Fire change event - onChange?.(formatted); - }} - />, - ); - } - results.push( - Children.map(actions, (existingChild) => { - if (!isValidElement<{ className?: string }>(existingChild)) return null; - const existingProps = existingChild.props; - - return cloneElement(existingChild, { - ...existingProps, - className: classNames(existingProps.className, actionClassName), - }); - }), - ); - return results; - }, [actions, format, onChange]); - - const cmContainer = ( -
- ); - - if (singleLine || containerOnly) { - return cmContainer; - } - return ( -
- {cmContainer} - {decoratedActions && ( - - {decoratedActions} - - )} -
+ ); } -function getExtensions({ - stateKey, - container, - singleLine, - hideGutter, - onChange, - onPaste, - onPasteOverwrite, - onFocus, - onBlur, - onKeyDown, -}: Pick & { - stateKey: EditorProps["stateKey"]; - container: HTMLDivElement | null; - onChange: RefObject; - onPaste: RefObject; - onPasteOverwrite: RefObject; - onFocus: RefObject; - onBlur: RefObject; - onKeyDown: RefObject; -}) { - // TODO: Ensure tooltips render inside the dialog if we are in one. - const parent = - container?.closest('[role="dialog"]') ?? - document.querySelector("#cm-portal") ?? - undefined; - - return [ - ...baseExtensions, // Must be first - EditorView.contentAttributes.of({ - autocapitalize: "off", - autocorrect: "off", - // Keeps macOS Writing Tools from offering to write code for us - writingsuggestions: "false", - }), - EditorView.domEventHandlers({ - focus: () => { - onFocus.current?.(); - }, - blur: () => { - onBlur.current?.(); - }, - keydown: (e, view) => { - // Check if the hotkey matches the editor.autocomplete action - if (eventMatchesHotkey(e, "editor.autocomplete")) { - e.preventDefault(); - startCompletion(view); - return true; - } - onKeyDown.current?.(e); - }, - paste: (e, v) => { - const textData = e.clipboardData?.getData("text/plain") ?? ""; - onPaste.current?.(textData); - if (v.state.selection.main.from === 0 && v.state.selection.main.to === v.state.doc.length) { - onPasteOverwrite.current?.(e, textData); - } - }, - }), - tooltips({ parent }), - keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap), - ...(singleLine ? [singleLineExtensions()] : []), - ...(!singleLine ? multiLineExtensions({ hideGutter }) : []), - - // ------------------------ // - // Things that must be last // - // ------------------------ // - - EditorView.updateListener.of((update) => { - if (update.startState === update.state) return; - - if (onChange && update.docChanged) { - onChange.current?.(update.state.doc.toString()); - } - - saveCachedEditorStateDebounced(stateKey, update.state); - }), - ]; +/** The client's own languages bring their extension along, so CodeEditor needn't know them */ +function codeEditorLanguage(language: EditorProps["language"]): CodeEditorProps["language"] { + return language === "url" || language === "pairs" || language === "timeline" ? null : language; } -const placeholderElFromText = (text: string | undefined) => { - const el = document.createElement("div"); - // Default to because codemirror needs it for sizing. I'm not sure why, but probably something - // to do with how Yaak "hacks" it with CSS for single line input. - el.innerHTML = text ? text.replaceAll("\n", "
") : " "; - return el; -}; - -// Caching the state is too expensive to do on every update (every keystroke and cursor move), -// so debounce it per state key and flush when the editor unmounts. -// -// The cost scales with the document, and every update pays it in full: -// - `state.toJSON` flattens the whole rope into a string, ~0.13 ms per 200 KB -// - the fingerprint is an exact md5 for editable documents, ~0.3 ms per 200 KB. `docFingerprint` -// only samples read-only ones, so editing a large body still hashes all of it -// - `sessionStorage.setItem` is synchronous and blocks the main thread -// Typing in a 200 KB body costs ~0.4 ms per keystroke before the storage write, and a 1 MB one -// ~2.3 ms. Read-only documents skip most of the hashing but still serialize, which is the larger -// half once documents get into the megabytes. -const SAVE_STATE_DEBOUNCE_MS = 500; -const stateSavers = new Map>(); - -function saveCachedEditorStateDebounced(stateKey: string | null, state: EditorState) { - if (!stateKey) return; - let saver = stateSavers.get(stateKey); - if (saver == null) { - saver = debounce( - (s: EditorState) => saveCachedEditorState(stateKey, s), - SAVE_STATE_DEBOUNCE_MS, - ); - stateSavers.set(stateKey, saver); - } - saver(state); -} - -// NOTE: Only called when an editor unmounts, so the saver is dropped rather than left in the map -// for every state key the session has ever shown. A pending saver holds the last EditorState, -// which holds the whole document. -function flushCachedEditorState(stateKey: string | null) { - if (!stateKey) return; - const saver = stateSavers.get(stateKey); - if (saver == null) return; - saver.flush(); - stateSavers.delete(stateKey); -} - -function saveCachedEditorState(stateKey: string | null, state: EditorState | null) { - if (!stateKey || state == null) return; - const stateObj = state.toJSON(stateFields); - - // Save state in sessionStorage by removing doc and saving a fingerprint of it instead. - // This will be checked on restore and put back in if it matches. - // Editable documents get the exact hash, so a collision can never restore undo history - // belonging to other content - stateObj.docHash = docFingerprint(stateObj.doc, { exact: !state.readOnly }); - stateObj.doc = undefined; - - try { - sessionStorage.setItem(computeFullStateKey(stateKey), JSON.stringify(stateObj)); - } catch (err) { - console.log("Failed to save to editor state", stateKey, err); - } -} - -function getCachedEditorState(doc: string, stateKey: string | null, readOnly: boolean) { - if (stateKey == null) return; - - try { - const stateStr = sessionStorage.getItem(computeFullStateKey(stateKey)); - if (stateStr == null) return null; - - const { docHash, ...state } = JSON.parse(stateStr); - - // Ensure the doc matches the one that was used to save the state - if (docHash !== docFingerprint(doc, { exact: !readOnly })) { - return null; - } - - state.doc = doc; - return state; - } catch (err) { - console.log("Failed to restore editor storage", stateKey, err); - } - - return null; -} - -function computeFullStateKey(stateKey: string): string { - return `editor.${stateKey}`; -} - -function updateContents(view: EditorView, text: string) { - // Replace codemirror contents - const currentDoc = view.state.doc.toString(); - - if (currentDoc === text) { - return; - } - - if (text.startsWith(currentDoc)) { - // If we're just appending, append only the changes. This preserves - // things like scroll position. - view.dispatch({ - changes: view.state.changes({ - from: currentDoc.length, - insert: text.slice(currentDoc.length), - }), - }); - } else { - // If we're replacing everything, reset the entire content - view.dispatch({ - changes: view.state.changes({ - from: 0, - to: currentDoc.length, - insert: text, - }), - }); - } +function startCompletionOnHotkey(e: KeyboardEvent, view: EditorView): boolean { + if (!eventMatchesHotkey(e, "editor.autocomplete")) return false; + e.preventDefault(); + startCompletion(view); + return true; } diff --git a/apps/yaak-client/components/core/Editor/extensions.ts b/apps/yaak-client/components/core/Editor/extensions.ts index 29be56cba..80eff01cc 100644 --- a/apps/yaak-client/components/core/Editor/extensions.ts +++ b/apps/yaak-client/components/core/Editor/extensions.ts @@ -1,141 +1,30 @@ -import { - autocompletion, - closeBrackets, - closeBracketsKeymap, - completionKeymap, -} from "@codemirror/autocomplete"; -import { history, historyKeymap } from "@codemirror/commands"; -import { go } from "@codemirror/lang-go"; -import { java } from "@codemirror/lang-java"; -import { javascript } from "@codemirror/lang-javascript"; -import { markdown } from "@codemirror/lang-markdown"; -import { php } from "@codemirror/lang-php"; -import { python } from "@codemirror/lang-python"; -import { xml } from "@codemirror/lang-xml"; -import { - bracketMatching, - codeFolding, - foldGutter, - foldKeymap, - HighlightStyle, - indentOnInput, - LanguageSupport, - StreamLanguage, - syntaxHighlighting, -} from "@codemirror/language"; -import { c, csharp, kotlin, objectiveC } from "@codemirror/legacy-modes/mode/clike"; -import { clojure } from "@codemirror/legacy-modes/mode/clojure"; -import { http } from "@codemirror/legacy-modes/mode/http"; -import { oCaml } from "@codemirror/legacy-modes/mode/mllike"; -import { powerShell } from "@codemirror/legacy-modes/mode/powershell"; -import { r } from "@codemirror/legacy-modes/mode/r"; -import { ruby } from "@codemirror/legacy-modes/mode/ruby"; -import { shell } from "@codemirror/legacy-modes/mode/shell"; -import { swift } from "@codemirror/legacy-modes/mode/swift"; -import { linter, lintGutter, lintKeymap } from "@codemirror/lint"; -import { search, searchKeymap } from "@codemirror/search"; +import { linter } from "@codemirror/lint"; import type { Extension } from "@codemirror/state"; -import { EditorState } from "@codemirror/state"; import { - crosshairCursor, - drawSelection, - dropCursor, - EditorView, - highlightActiveLineGutter, - highlightSpecialChars, - keymap, - lineNumbers, - rectangularSelection, -} from "@codemirror/view"; -import { tags as t } from "@lezer/highlight"; -import { jsonc, jsoncLanguage } from "@shopify/lang-jsonc"; + closeBracketsExtensions, + getLanguageExtension as getBaseLanguageExtension, + type LanguageSupports, + readonlyExtensions as baseReadonlyExtensions, + syntaxExtensions, +} from "@yaakapp-internal/ui/editor"; import { graphql } from "cm6-graphql"; import type { GraphQLSchema } from "graphql"; import { activeRequestIdAtom } from "../../../hooks/useActiveRequestId"; import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables"; import { jotaiStore } from "../../../lib/jotai"; import { renderMarkdown } from "../../../lib/markdown"; -import { pluralizeCount } from "../../../lib/pluralize"; import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms"; import type { EditorProps } from "./Editor"; import { jsonParseLinter } from "./json-lint"; -import { largeValues, mediaValues } from "./largeValues"; +import { largeValues } from "./largeValues"; import { pairs } from "./pairs/extension"; -import { searchMatchCount } from "./searchMatchCount"; -import { text } from "./text/extension"; import { timeline } from "./timeline/extension"; import type { TwigCompletionOption } from "./twig/completion"; import { twig } from "./twig/extension"; import { pathParametersPlugin } from "./twig/pathParameters"; import { url } from "./url/extension"; -export const syntaxHighlightStyle = HighlightStyle.define([ - { - tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment], - color: "var(--textSubtlest)", - }, - { - tag: [t.emphasis], - textDecoration: "underline", - }, - { - tag: [t.angleBracket, t.paren, t.bracket, t.squareBracket, t.brace, t.separator, t.punctuation], - color: "var(--textSubtle)", - }, - { - tag: [t.link, t.name, t.tagName, t.angleBracket, t.docString, t.number], - color: "var(--info)", - }, - { tag: [t.variableName], color: "var(--success)" }, - { tag: [t.bool], color: "var(--warning)" }, - { tag: [t.attributeName, t.propertyName], color: "var(--primary)" }, - { tag: [t.attributeValue], color: "var(--warning)" }, - { tag: [t.string], color: "var(--notice)" }, - { tag: [t.atom, t.meta, t.operator, t.bool, t.null, t.keyword], color: "var(--danger)" }, -]); - -const syntaxTheme = EditorView.theme({}, { dark: true }); - -const closeBracketsExtensions: Extension = [closeBrackets(), keymap.of([...closeBracketsKeymap])]; - -const legacyLang = (mode: Parameters[0]) => { - return () => new LanguageSupport(StreamLanguage.define(mode)); -}; - -const syntaxExtensions: Record< - NonNullable, - null | (() => LanguageSupport) -> = { - graphql: null, - json: jsonc, - javascript: javascript, - // HTML as XML because HTML is oddly slow - html: xml, - xml: xml, - url: url, - pairs: pairs, - text: text, - timeline: timeline, - markdown: markdown, - c: legacyLang(c), - clojure: legacyLang(clojure), - csharp: legacyLang(csharp), - go: go, - http: legacyLang(http), - java: java, - kotlin: legacyLang(kotlin), - objective_c: legacyLang(objectiveC), - ocaml: legacyLang(oCaml), - php: php, - powershell: legacyLang(powerShell), - python: python, - r: legacyLang(r), - ruby: legacyLang(ruby), - shell: legacyLang(shell), - swift: legacyLang(swift), -}; - -const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ["json", "javascript", "graphql"]; +const languages: LanguageSupports = { ...syntaxExtensions, url, pairs, timeline }; export function getLanguageExtension({ useTemplating, @@ -157,18 +46,7 @@ export function getLanguageExtension({ onClickPathParameter: (name: string) => void; completionOptions: TwigCompletionOption[]; graphQLSchema: GraphQLSchema | null; -} & Pick) { - const extraExtensions: Extension[] = []; - - if (language === "url") { - extraExtensions.push(pathParametersPlugin(onClickPathParameter)); - } - - // Only close brackets on languages that need it - if (language && closeBracketsFor.includes(language)) { - extraExtensions.push(closeBracketsExtensions); - } - +} & Pick): Extension { // GraphQL is a special exception if (language === "graphql") { return [ @@ -189,155 +67,31 @@ export function getLanguageExtension({ })); }, }), - extraExtensions, + [closeBracketsExtensions], ]; } - if (language === "json") { - extraExtensions.push(lintExtension ?? linter(jsonParseLinter())); - extraExtensions.push( - jsoncLanguage.data.of({ - commentTokens: { line: "//", block: { open: "/*", close: "*/" } }, - }), - ); - if (!hideGutter) { - extraExtensions.push(lintGutter()); - } - } - - const maybeBase = language ? syntaxExtensions[language] : null; - const base = typeof maybeBase === "function" ? maybeBase() : null; - if (base == null) { - return []; - } - - if (!useTemplating) { - return [base, extraExtensions]; - } - - return twig({ - base, - environmentVariables, - completionOptions, - autocomplete, - onClickVariable, - onClickMissingVariable, - onClickPathParameter, - extraExtensions, + return getBaseLanguageExtension({ + language, + hideGutter, + languages, + lintExtension: language === "json" ? (lintExtension ?? linter(jsonParseLinter())) : undefined, + extraExtensions: language === "url" ? [pathParametersPlugin(onClickPathParameter)] : [], + wrap: useTemplating + ? (base, extraExtensions) => + twig({ + base, + environmentVariables, + completionOptions, + autocomplete, + onClickVariable, + onClickMissingVariable, + onClickPathParameter, + extraExtensions, + }) + : undefined, }); } -// Filter out autocomplete start triggers from completionKeymap since we handle it via configurable hotkeys. -// Keep navigation keys (ArrowUp/Down, Enter, Escape, etc.) but remove startCompletion bindings. -const filteredCompletionKeymap = completionKeymap.filter((binding) => { - const key = binding.key?.toLowerCase() ?? ""; - const mac = (binding as { mac?: string }).mac?.toLowerCase() ?? ""; - // Filter out Ctrl-Space and Mac-specific autocomplete triggers (Alt-`, Alt-i) - const isStartTrigger = key.includes("space") || mac.includes("alt-") || mac.includes("`"); - return !isStartTrigger; -}); - -export const baseExtensions = [ - highlightSpecialChars(), - history(), - dropCursor(), - drawSelection(), - autocompletion({ - tooltipClass: () => "x-theme-menu", - closeOnBlur: true, // Set to `false` for debugging in devtools without closing it - defaultKeymap: false, // We handle the trigger via configurable hotkeys - compareCompletions: (a, b) => { - // Don't sort completions at all, only on boost - return (a.boost ?? 0) - (b.boost ?? 0); - }, - }), - syntaxHighlighting(syntaxHighlightStyle), - syntaxTheme, - keymap.of([...historyKeymap, ...filteredCompletionKeymap]), -]; - -export const readonlyExtensions = [ - EditorState.readOnly.of(true), - EditorView.contentAttributes.of({ tabindex: "-1" }), - // Nobody is editing this, so every rule applies - largeValues, -]; - -/** - * The counterpart for a document being edited. - * - * Only encoded media collapses here: it is pasted rather than typed, and replaced rather than - * edited, so a tag stands in for it without hiding anything anyone meant to read. The rules that - * hide text by length alone stay out. See {@link mediaValues}. - */ -export const editableExtensions = [mediaValues]; - -export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [ - search({ top: true }), - searchMatchCount(), - hideGutter - ? [] - : [ - lineNumbers(), - foldGutter({ - markerDOM: (open) => { - const el = document.createElement("div"); - el.classList.add("fold-gutter-icon"); - el.tabIndex = -1; - if (open) { - el.setAttribute("data-open", ""); - } - return el; - }, - }), - ], - codeFolding({ - placeholderDOM(_view, onclick, prepared) { - const el = document.createElement("span"); - el.onclick = onclick; - el.className = "cm-foldPlaceholder"; - el.innerText = prepared || "…"; - el.title = "unfold"; - el.ariaLabel = "folded code"; - return el; - }, - /** - * Show the number of items when code folded. NOTE: this doesn't get called when restoring - * a previous serialized editor state, which is a bummer - */ - preparePlaceholder(state, range) { - let count: number | undefined; - let startToken = "{"; - let endToken = "}"; - - const prevLine = state.doc.lineAt(range.from).text; - const isArray = prevLine.lastIndexOf("[") > prevLine.lastIndexOf("{"); - - if (isArray) { - startToken = "["; - endToken = "]"; - } - - const internal = state.sliceDoc(range.from, range.to); - const toParse = startToken + internal + endToken; - - try { - const parsed = JSON.parse(toParse); - count = Object.keys(parsed).length; - } catch { - /* empty */ - } - - if (count !== undefined) { - const label = isArray ? "item" : "key"; - return pluralizeCount(label, count); - } - }, - }), - indentOnInput(), - rectangularSelection(), - crosshairCursor(), - bracketMatching(), - highlightActiveLineGutter(), - keymap.of([...searchKeymap, ...foldKeymap, ...lintKeymap]), -]; +// Nobody is editing this, so every rule applies +export const readonlyExtensions = [...baseReadonlyExtensions, largeValues]; diff --git a/apps/yaak-client/components/core/Editor/genericCompletion.ts b/apps/yaak-client/components/core/Editor/genericCompletion.ts index 630da4cde..239a4896b 100644 --- a/apps/yaak-client/components/core/Editor/genericCompletion.ts +++ b/apps/yaak-client/components/core/Editor/genericCompletion.ts @@ -1,15 +1,8 @@ -import type { Completion, CompletionContext } from "@codemirror/autocomplete"; -import type { GenericCompletionOption } from "@yaakapp-internal/plugins"; +import type { CompletionContext } from "@codemirror/autocomplete"; +import type { GenericCompletionConfig } from "@yaakapp-internal/ui"; import { defaultBoost } from "./twig/completion"; -export type GenericCompletion = GenericCompletionOption & { - apply?: Completion["apply"]; -}; - -export interface GenericCompletionConfig { - minMatch?: number; - options: GenericCompletion[]; -} +export type { GenericCompletion, GenericCompletionConfig } from "@yaakapp-internal/ui"; /** * Complete options, always matching until the start of the line diff --git a/apps/yaak-client/components/core/Editor/json-lint.test.ts b/apps/yaak-client/components/core/Editor/json-lint.test.ts new file mode 100644 index 000000000..58c5c40f0 --- /dev/null +++ b/apps/yaak-client/components/core/Editor/json-lint.test.ts @@ -0,0 +1,46 @@ +import type { EditorView } from "@codemirror/view"; +import { describe, expect, test } from "vite-plus/test"; +import { jsonParseLinter } from "./json-lint"; + +// The linter only reads the document text off the view +function view(doc: string): EditorView { + return { state: { doc: { toString: () => doc } } } as EditorView; +} + +const lint = jsonParseLinter(); + +describe("jsonParseLinter", () => { + test("reports nothing for an empty document", () => { + expect(lint(view(" "))).toEqual([]); + }); + + test("reports nothing for valid JSON", () => { + expect(lint(view('{"a": 1}'))).toEqual([]); + }); + + test("reports a message for invalid JSON", () => { + expect(lint(view('{"a": }'))).toHaveLength(1); + }); + + test("ignores a template tag standing in for a value", () => { + expect(lint(view('{"a": "${[ my_var ]}"}'))).toEqual([]); + }); + + test("ignores a tag whose quoted argument contains `]}`", () => { + expect(lint(view(`{"a": "\${[ fn(arg='x]}y') ]}"}`))).toEqual([]); + }); + + test("ignores a tag whose quoted argument contains an escaped quote", () => { + expect(lint(view(`{"a": "\${[ fn(arg='it\\'s ]}') ]}"}`))).toEqual([]); + }); + + test("still reports errors around a tag with a quoted `]}`", () => { + expect(lint(view(`{"a": "\${[ fn(arg='x]}y') ]}",}`))).toHaveLength(1); + }); + + test("keeps the tag's length so error offsets stay correct", () => { + const doc = `{"a" "\${[ fn(arg='x]}y') ]}"}`; + const [error] = lint(view(doc)); + expect(error?.from).toEqual(doc.indexOf('"${[')); + }); +}); diff --git a/apps/yaak-client/components/core/Editor/json-lint.ts b/apps/yaak-client/components/core/Editor/json-lint.ts index a61ffc109..3b0d91f79 100644 --- a/apps/yaak-client/components/core/Editor/json-lint.ts +++ b/apps/yaak-client/components/core/Editor/json-lint.ts @@ -1,58 +1,15 @@ -import type { Diagnostic } from "@codemirror/lint"; -import type { EditorView } from "@codemirror/view"; -import { type ParseError, parse, printParseErrorCode } from "jsonc-parser"; +import { + type JsonLintOptions, + jsonParseLinter as baseJsonParseLinter, +} from "@yaakapp-internal/ui/editor"; +import { replaceTemplateTags } from "../../../lib/templateTags"; -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 = { - 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; +export function jsonParseLinter(options?: Omit) { + return baseJsonParseLinter({ ...options, escape: escapeTemplateTags }); } -export function jsonParseLinter(options?: JsonLintOptions) { - return (view: EditorView): Diagnostic[] => { - 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 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", - }, - ]; - }; +// 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. +function escapeTemplateTags(doc: string): string { + return replaceTemplateTags(doc, (m) => "1".repeat(m.end - m.start)); } diff --git a/apps/yaak-client/components/core/Editor/largeValues.test.ts b/apps/yaak-client/components/core/Editor/largeValues.test.ts index 6df9f0aa6..abd1b6ba5 100644 --- a/apps/yaak-client/components/core/Editor/largeValues.test.ts +++ b/apps/yaak-client/components/core/Editor/largeValues.test.ts @@ -1,6 +1,5 @@ import { EditorState } from "@codemirror/state"; import { jsonc } from "@shopify/lang-jsonc"; -import { text } from "./text/extension"; import { twig } from "./twig/extension"; import { describe, expect, test } from "vite-plus/test"; import { @@ -11,6 +10,7 @@ import { MAX_VISIBLE_LINE_CHARS, } from "./largeValues"; import type { SniffedValue } from "./sniffValue"; +import { text } from "@yaakapp-internal/ui/editor"; const BIG = "A".repeat(1_000_000); diff --git a/apps/yaak-client/components/core/Editor/renderEditor.tsx b/apps/yaak-client/components/core/Editor/renderEditor.tsx new file mode 100644 index 000000000..4512115a5 --- /dev/null +++ b/apps/yaak-client/components/core/Editor/renderEditor.tsx @@ -0,0 +1,7 @@ +import type { CodeEditorProps } from "@yaakapp-internal/ui/editor"; +import { Editor } from "./LazyEditor"; + +/** Renders a ui component's editor as the client's, with its settings and value handling */ +export function renderEditor({ extensions, ...props }: CodeEditorProps) { + return ; +} diff --git a/apps/yaak-client/components/core/Editor/twig/completion.test.ts b/apps/yaak-client/components/core/Editor/twig/completion.test.ts new file mode 100644 index 000000000..c02cc65d7 --- /dev/null +++ b/apps/yaak-client/components/core/Editor/twig/completion.test.ts @@ -0,0 +1,155 @@ +/* oxlint-disable no-template-curly-in-string */ +import { CompletionContext } from "@codemirror/autocomplete"; +import { json } from "@codemirror/lang-json"; +import { EditorState, type Extension, type TransactionSpec } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; +import { describe, expect, test } from "vite-plus/test"; +import { twigCompletion, type TwigCompletionOption } from "./completion"; +import { parser } from "./twig"; +import { singleLineExtensions } from "@yaakapp-internal/ui/editor"; + +const options: TwigCompletionOption[] = [ + { name: "someVar", label: "someVar", type: "variable", value: "value", onClick: () => {} }, + { name: "uuid.v4", label: "uuid.v4", type: "function", args: [], value: null, onClick: () => {} }, +]; +const source = twigCompletion({ options }); + +// Both keyboard acceptance and clicking a suggestion invoke Completion.apply. +function complete(input: string, label: string, extensions: Extension = []) { + const cursor = input.indexOf("|"); + if (cursor < 0) throw new Error("Missing cursor marker"); + let state = EditorState.create({ + doc: input.slice(0, cursor) + input.slice(cursor + 1), + selection: { anchor: cursor }, + extensions, + }); + const result = source(new CompletionContext(state, cursor, false)); + const completion = result?.options.find((option) => option.label === label); + expect(completion).toBeDefined(); + const view = { + get state() { + return state; + }, + dispatch(spec: TransactionSpec) { + state = state.update(spec).state; + }, + } as EditorView; + if (typeof completion?.apply !== "function" || !result) throw new Error("Missing apply"); + completion.apply(view, completion, result.from, cursor); + return state; +} + +function expectValidTag(state: EditorState, expected: string) { + expect(state.doc.toString()).toBe(expected); + const tags: string[] = []; + parser.parse(expected).iterate({ + enter(node) { + expect(node.type.isError).toBe(false); + if (node.name === "Tag") tags.push(expected.slice(node.from, node.to)); + }, + }); + expect(tags).toHaveLength(1); +} + +describe("twig completion", () => { + test.each(["${[\nsome|", "${[ \n\n\tsome|", "${[\nsome|\n ]}", "${[\n |"])( + "completes across multiline whitespace: %s", + (input) => expectValidTag(complete(input, "someVar"), "${[ someVar ]}"), + ); + + test.each(["${[\nuuid.v|", "${[\n uuid.v|()\n ]}"])( + "completes a function across multiline whitespace: %s", + (input) => expectValidTag(complete(input, "uuid.v4"), "${[ uuid.v4() ]}"), + ); + + test("preserves surrounding lines and literal pipes", () => { + expect(complete("before\n${[\nsome|\n]}\na | b", "someVar").doc.toString()).toBe( + "before\n${[ someVar ]}\na | b", + ); + }); + + test("does not search past non-whitespace for an opener or closer", () => { + expect(complete("prefix\nsome|\nother ]}", "someVar").doc.toString()).toBe( + "prefix\n${[ someVar ]}\nother ]}", + ); + expect(complete("${[some|\nother ]}", "someVar").doc.toString()).toBe( + "${[ someVar ]}\nother ]}", + ); + }); + + test.each(["${[|", "${[ |", "${[ |"])( + "offers completion immediately after a template opener: %s", + (input) => { + expectValidTag(complete(input, "someVar"), "${[ someVar ]}"); + }, + ); + + test.each(["$", "{", "${", '{"value": "'])( + "does not automatically offer empty-name completions after %s", + (doc) => { + const state = EditorState.create({ doc }); + expect(source(new CompletionContext(state, doc.length, false))).toBeNull(); + }, + ); + + test.each(["uuid|", "${[uuid|", "${[ uuid| ]}"])( + "completes a namespace before wrapping the final function: %s", + (input) => { + const namespace = complete(input, "uuid.*"); + expect(namespace.doc.toString()).toBe(input.replace("uuid|", "uuid.")); + const cursor = namespace.selection.main.head; + const next = namespace.sliceDoc(0, cursor) + "|" + namespace.sliceDoc(cursor); + expectValidTag(complete(next, "uuid.v4"), "${[ uuid.v4() ]}"); + }, + ); + + test.each(["some|", "${[some|", "${[ some|", "${[ someVar|", "${[\tsome|"])( + "completes a variable in a single-line value: %s", + (input) => { + const state = complete(input, "someVar", singleLineExtensions()); + expectValidTag(state, "${[ someVar ]}"); + expect(state.selection.main.head).toBe(state.doc.length); + }, + ); + + test.each(["${[some|]}", "${[ some| ]}", "${[someVar|]}"])( + "reuses closing delimiters: %s", + (input) => { + expectValidTag(complete(input, "someVar"), "${[ someVar ]}"); + }, + ); + + test.each(["uuid.v|", "${[uuid.v|", "${[ uuid.v| ]}", "${[uuid.v|()]}"])( + "completes a function: %s", + (input) => { + expectValidTag(complete(input, "uuid.v4"), "${[ uuid.v4() ]}"); + }, + ); + + test("preserves surrounding JSON and its closing punctuation", () => { + expectValidTag( + complete('{"value":"${[some|","other":1}', "someVar", json()), + '{"value":"${[ someVar ]}","other":1}', + ); + }); + + test("preserves surrounding text and other complete tags", () => { + expect(complete("${[ someVar ]} ${[some|]} trailing", "someVar").doc.toString()).toBe( + "${[ someVar ]} ${[ someVar ]} trailing", + ); + }); + + test("does not consume closing punctuation without a template opener", () => { + expect(complete("[some|]}", "someVar").doc.toString()).toBe("[${[ someVar ]}]}"); + }); + + test("invalidates cached suggestions when typing delimiters or a namespace separator", () => { + const state = EditorState.create({ doc: "some" }); + const result = source(new CompletionContext(state, 4, false)); + expect(result?.validFor).toBeInstanceOf(RegExp); + const validFor = result?.validFor as RegExp; + expect(validFor.test("someVar")).toBe(true); + expect(validFor.test("some ${[some")).toBe(false); + expect(validFor.test("uuid.")).toBe(false); + }); +}); diff --git a/apps/yaak-client/components/core/Editor/twig/completion.ts b/apps/yaak-client/components/core/Editor/twig/completion.ts index 39793223c..ae0a979b8 100644 --- a/apps/yaak-client/components/core/Editor/twig/completion.ts +++ b/apps/yaak-client/components/core/Editor/twig/completion.ts @@ -1,5 +1,6 @@ import type { Completion, CompletionContext } from "@codemirror/autocomplete"; import { startCompletion } from "@codemirror/autocomplete"; +import type { EditorState } from "@codemirror/state"; import type { TemplateFunction } from "@yaakapp-internal/plugins"; const openTag = "${[ "; @@ -36,6 +37,22 @@ export interface TwigCompletionConfig { const MIN_MATCH_NAME = 1; +// Only inspect adjacent whitespace, including newlines, rather than copying the +// entire document to look for a delimiter in large request bodies. +function skipWhitespace(state: EditorState, pos: number, direction: -1 | 1) { + while (direction === -1 ? pos > 0 : pos < state.doc.length) { + const from = direction === -1 ? pos - 1 : pos; + if (!/\s/.test(state.sliceDoc(from, from + 1))) break; + pos += direction; + } + return pos; +} + +function findOpenTag(state: EditorState, from: number) { + const end = skipWhitespace(state, from, -1); + return end >= 3 && state.sliceDoc(end - 3, end) === "${[" ? end - 3 : null; +} + export function twigCompletion({ options }: TwigCompletionConfig) { return function completions(context: CompletionContext) { const toStartOfName = context.matchBefore(/[\w_.]*/); @@ -44,7 +61,8 @@ export function twigCompletion({ options }: TwigCompletionConfig) { if (toMatch === null) return null; const matchLen = toMatch.to - toMatch.from; - if (!context.explicit && toMatch.from > 0 && matchLen < MIN_MATCH_NAME) { + const hasOpenTag = findOpenTag(context.state, toMatch.from) !== null; + if (!context.explicit && !hasOpenTag && toMatch.from > 0 && matchLen < MIN_MATCH_NAME) { return null; } @@ -83,6 +101,16 @@ export function twigCompletion({ options }: TwigCompletionConfig) { detail: o.type, type: o.type === "variable" ? "variable" : "function", apply: (view, _completion, from, to) => { + // Keep the completion range name-only for filtering, but replace a manually + // typed opener when accepting (through either the keyboard or the menu). + const opener = findOpenTag(view.state, from); + if (opener !== null) { + from = opener; + const hasParentheses = + o.type === "function" && view.state.sliceDoc(to, to + 2) === "()"; + const closer = skipWhitespace(view.state, to + (hasParentheses ? 2 : 0), 1); + if (view.state.sliceDoc(closer, closer + 2) === "]}") to = closer + 2; + } const insert = openTag + inner + closeTag; view.dispatch({ changes: { from, to, insert: insert }, @@ -103,7 +131,8 @@ export function twigCompletion({ options }: TwigCompletionConfig) { return { matchLen, - validFor: () => true, // Not really sure why this is all it needs + // Delimiters change the replacement range; dots change namespace options. + validFor: /^\w*$/, from: toMatch.from, options: sortedCompletions, }; diff --git a/apps/yaak-client/components/core/Editor/twig/extension.ts b/apps/yaak-client/components/core/Editor/twig/extension.ts index bbd4c8cfc..54d72a42d 100644 --- a/apps/yaak-client/components/core/Editor/twig/extension.ts +++ b/apps/yaak-client/components/core/Editor/twig/extension.ts @@ -5,11 +5,11 @@ import { parseMixed } from "@lezer/common"; import type { WrappedEnvironmentVariable } from "../../../../hooks/useEnvironmentVariables"; import type { GenericCompletionConfig } from "../genericCompletion"; import { genericCompletion } from "../genericCompletion"; -import { textLanguage } from "../text/extension"; import type { TwigCompletionOption } from "./completion"; import { twigCompletion } from "./completion"; import { templateTagsPlugin } from "./templateTags"; import { parser as twigParser } from "./twig"; +import { textLanguage } from "@yaakapp-internal/ui/editor"; export function twig({ base, diff --git a/apps/yaak-client/components/core/Editor/twig/highlight.ts b/apps/yaak-client/components/core/Editor/twig/highlight.ts index d3a614ec3..2645ade59 100644 --- a/apps/yaak-client/components/core/Editor/twig/highlight.ts +++ b/apps/yaak-client/components/core/Editor/twig/highlight.ts @@ -4,4 +4,6 @@ export const highlight = styleTags({ TagOpen: t.bracket, TagClose: t.bracket, TagContent: t.keyword, + // Strings are part of the tag body, so they get the same color as the rest of it + TagString: t.keyword, }); diff --git a/apps/yaak-client/components/core/Editor/twig/twig.grammar b/apps/yaak-client/components/core/Editor/twig/twig.grammar index 6ca447c40..fecf4a84f 100644 --- a/apps/yaak-client/components/core/Editor/twig/twig.grammar +++ b/apps/yaak-client/components/core/Editor/twig/twig.grammar @@ -1,13 +1,17 @@ @top Template { (Tag | Text)* } +// `TagString` keeps a quoted argument from closing the tag, so `${[ fn(a='x]}y') ]}` stays a +// single Tag. Only a terminated string matches, so a half-typed `'` degrades to TagContent and +// the tag still closes at the next `]}`. @local tokens { TagClose { "]}" } + TagString { "'" (![\\'] | "\\" _)* "'" } @else TagContent } @skip { } { TagOpen { "${[" } - Tag { TagOpen (TagContent)+ TagClose } + Tag { TagOpen (TagContent | TagString)+ TagClose } } @tokens { diff --git a/apps/yaak-client/components/core/Editor/twig/twig.terms.ts b/apps/yaak-client/components/core/Editor/twig/twig.terms.ts index 7ac3c791f..053c411f1 100644 --- a/apps/yaak-client/components/core/Editor/twig/twig.terms.ts +++ b/apps/yaak-client/components/core/Editor/twig/twig.terms.ts @@ -3,5 +3,6 @@ export const Template = 1, Tag = 2, TagOpen = 3, TagContent = 4, - TagClose = 5, - Text = 6; + TagString = 5, + TagClose = 6, + Text = 7; diff --git a/apps/yaak-client/components/core/Editor/twig/twig.test.ts b/apps/yaak-client/components/core/Editor/twig/twig.test.ts index 2ce95f01f..5d5d3527a 100644 --- a/apps/yaak-client/components/core/Editor/twig/twig.test.ts +++ b/apps/yaak-client/components/core/Editor/twig/twig.test.ts @@ -23,6 +23,10 @@ function hasError(input: string): boolean { return getNodeNames(input).includes("⚠"); } +function countTags(input: string): number { + return getNodeNames(input).filter((n) => n === "Tag").length; +} + describe("twig grammar", () => { describe("${[var]} format (valid template tags)", () => { test("parses simple variable as Tag", () => { @@ -83,6 +87,50 @@ describe("twig grammar", () => { }); }); + describe("quoted strings inside tags", () => { + test("keeps a quoted `]}` inside the tag", () => { + const input = "${[ fn(a='x]}y') ]}"; + expect(countTags(input)).toBe(1); + expect(hasError(input)).toBe(false); + }); + + test("keeps an escaped quote inside the string", () => { + const input = "${[ fn(a='it\\'s ]}') ]}"; + expect(countTags(input)).toBe(1); + expect(hasError(input)).toBe(false); + }); + + test("parses a b64 string as one Tag", () => { + const input = "${[ b64'Zm9v' ]}"; + expect(countTags(input)).toBe(1); + expect(hasError(input)).toBe(false); + }); + + test("closes at the first `]}` when a quote is left unterminated", () => { + // Half-typed strings must not swallow the rest of the document + const input = "${[ fn(a='unterminated ]} after"; + expect(countTags(input)).toBe(1); + expect(hasError(input)).toBe(false); + const tag = parser.parse(input).topNode.getChild("Tag"); + expect(input.slice(tag?.from ?? 0, tag?.to ?? 0)).toBe("${[ fn(a='unterminated ]}"); + }); + + test("handles two tags with quoted `]}` in the same string", () => { + const input = "${[ a(x='1]}') ]} and ${[ b(y='2]}') ]}"; + expect(countTags(input)).toBe(2); + expect(hasError(input)).toBe(false); + }); + + test("keeps JSON around a tag with a quoted `]}` intact", () => { + const tag = "${[ fn(a='x]}y') ]}"; + const json = '{"a": "' + tag + '"}'; + expect(countTags(json)).toBe(1); + expect(hasError(json)).toBe(false); + const node = parser.parse(json).topNode.getChild("Tag"); + expect(json.slice(node?.from ?? 0, node?.to ?? 0)).toBe(tag); + }); + }); + describe("edge cases", () => { test("handles $ at end of string", () => { expect(hasError("hello$")).toBe(false); diff --git a/apps/yaak-client/components/core/Editor/twig/twig.ts b/apps/yaak-client/components/core/Editor/twig/twig.ts index 78af862c7..181356235 100644 --- a/apps/yaak-client/components/core/Editor/twig/twig.ts +++ b/apps/yaak-client/components/core/Editor/twig/twig.ts @@ -4,17 +4,24 @@ import { highlight } from "./highlight"; export const parser = LRParser.deserialize({ version: 14, states: - "!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cc'#CcQQOPOOOOOO'#Cd'#CdO_OQO,58xOOOO-E6a-E6aOOOO-E6b-E6bOOOO1G.d1G.d", - stateData: "g~OUROYPO~OSTO~OSTOTXO~O", - goto: "nXPPY^PPPbhTROSTQOSQSORVSQUQRWU", - nodeNames: "⚠ Template Tag TagOpen TagContent TagClose Text", - maxTerm: 10, + "!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cd'#CdQQOPOOOOOO'#Ce'#CeObOQO,58xOOOO-E6b-E6bOOOO-E6c-E6cOOOO1G.d1G.d", + stateData: "m~OVROZPO~OSTOTTO~OSTOTTOUXO~O", + goto: "oYPPZ_PPPPciTROSTQOSQSORVSQUQRWU", + nodeNames: "⚠ Template Tag TagOpen TagContent TagString TagClose Text", + maxTerm: 11, propSources: [highlight], skippedNodes: [0], repeatNodeCount: 2, tokenData: - "#{~RTOtbtu!zu;'Sb;'S;=`!o<%lOb~gTU~Otbtuvu;'Sb;'S;=`!o<%lOb~yVO#ob#o#p!`#p;'Sb;'S;=`!o<%l~b~Ob~~!u~!cSO!}b#O;'Sb;'S;=`!o<%lOb~!rP;=`<%lb~!zOU~~!}VO#ob#o#p#d#p;'Sb;'S;=`!o<%l~b~Ob~~!u~#gTO!}b!}#O#v#O;'Sb;'S;=`!o<%lOb~#{OY~", - tokenizers: [1, new LocalTokenGroup("b~RP#P#QU~XP#q#r[~aOT~~", 17, 4)], + "#{~RTOtbtu!zu;'Sb;'S;=`!o<%lOb~gTV~Otbtuvu;'Sb;'S;=`!o<%lOb~yVO#ob#o#p!`#p;'Sb;'S;=`!o<%l~b~Ob~~!u~!cSO!}b#O;'Sb;'S;=`!o<%lOb~!rP;=`<%lb~!zOV~~!}VO#ob#o#p#d#p;'Sb;'S;=`!o<%l~b~Ob~~!u~#gTO!}b!}#O#v#O;'Sb;'S;=`!o<%lOb~#{OZ~", + tokenizers: [ + 1, + new LocalTokenGroup( + "#R~RQwxX#P#Q!u~[VOwXwxqx#OX#O#Pv#P;'SX;'S;=`!o<%lOX~vOT~~yRO;'SX;'S;=`!S;=`OX~!VWOwXwxqx#OX#O#Pv#P;'SX;'S;=`!o;=`<%lX<%lOX~!rP;=`<%lX~!xP#q#r!{~#QOU~~", + 94, + 4, + ), + ], topRules: { Template: [0, 1] }, tokenPrec: 0, }); diff --git a/apps/yaak-client/components/core/Hotkey.tsx b/apps/yaak-client/components/core/Hotkey.tsx index faa86ceeb..fe8b3e9ca 100644 --- a/apps/yaak-client/components/core/Hotkey.tsx +++ b/apps/yaak-client/components/core/Hotkey.tsx @@ -1,45 +1 @@ -import { HStack } from "@yaakapp-internal/ui"; -import classNames from "classnames"; -import type { HotkeyAction } from "../../hooks/useHotKey"; -import { useFormattedHotkey } from "../../hooks/useHotKey"; - -interface Props { - action: HotkeyAction | null; - className?: string; - variant?: "text" | "with-bg"; -} - -export function Hotkey({ action, className, variant }: Props) { - const labelParts = useFormattedHotkey(action); - if (labelParts === null) { - return null; - } - - return ; -} - -interface HotkeyRawProps { - labelParts: string[]; - className?: string; - variant?: "text" | "with-bg"; -} - -export function HotkeyRaw({ labelParts, className, variant }: HotkeyRawProps) { - return ( - - {labelParts.map((char, index) => ( - // oxlint-disable-next-line react/no-array-index-key -
- {char} -
- ))} -
- ); -} +export { Hotkey, HotkeyRaw } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/core/HotkeyList.tsx b/apps/yaak-client/components/core/HotkeyList.tsx index 91f902547..751d3d3a3 100644 --- a/apps/yaak-client/components/core/HotkeyList.tsx +++ b/apps/yaak-client/components/core/HotkeyList.tsx @@ -1,31 +1 @@ -import classNames from "classnames"; -import type { ReactNode } from "react"; -import { Fragment } from "react"; -import type { HotkeyAction } from "../../hooks/useHotKey"; -import { Hotkey } from "./Hotkey"; -import { HotkeyLabel } from "./HotkeyLabel"; - -interface Props { - hotkeys: HotkeyAction[]; - bottomSlot?: ReactNode; - className?: string; -} - -export const HotkeyList = ({ hotkeys, bottomSlot, className }: Props) => { - return ( -
-
- {hotkeys.map((hotkey) => ( - - - {/* Keep this grid cell when Hotkey renders nothing so later rows stay aligned. */} -
- -
-
- ))} - {bottomSlot} -
-
- ); -}; +export { HotkeyList } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/core/HttpMethodTag.tsx b/apps/yaak-client/components/core/HttpMethodTag.tsx index 0184c2bfb..f2782d888 100644 --- a/apps/yaak-client/components/core/HttpMethodTag.tsx +++ b/apps/yaak-client/components/core/HttpMethodTag.tsx @@ -1,91 +1,22 @@ -import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models"; import { settingsAtom } from "@yaakapp-internal/models"; -import classNames from "classnames"; +import { + HttpMethodTag as BaseHttpMethodTag, + HttpMethodTagRaw as BaseHttpMethodTagRaw, +} from "@yaakapp-internal/ui"; import { useAtomValue } from "jotai"; -import { memo } from "react"; +import { type ComponentProps, memo } from "react"; -interface Props { - request: HttpRequest | GrpcRequest | WebsocketRequest; - className?: string; - short?: boolean; - noAlias?: boolean; -} - -const methodNames: Record = { - get: "GET", - put: "PUT", - post: "POST", - patch: "PTCH", - delete: "DELE", - options: "OPTN", - head: "HEAD", - query: "QURY", - graphql: "GQL", - grpc: "GRPC", - websocket: "WS", -}; - -export const HttpMethodTag = memo(function HttpMethodTag({ - request, - className, - short, - noAlias, -}: Props) { - const method = - request.model === "http_request" && request.bodyType === "graphql" && !noAlias - ? "graphql" - : request.model === "grpc_request" - ? "grpc" - : request.model === "websocket_request" - ? "websocket" - : request.method; - - return ; +export const HttpMethodTag = memo(function HttpMethodTag( + props: Omit, "colored">, +) { + const settings = useAtomValue(settingsAtom); + return ; }); export function HttpMethodTagRaw({ - className, - method, - short, forceColor, -}: { - method: string; - className?: string; - short?: boolean; - forceColor?: boolean; -}) { - let label = method.toUpperCase(); - if (short) { - label = methodNames[method.toLowerCase()] ?? method.slice(0, 4); - label = label.padEnd(4, " "); - } - - const m = method.toUpperCase(); - + ...props +}: Omit, "colored"> & { forceColor?: boolean }) { const settings = useAtomValue(settingsAtom); - const colored = forceColor || settings.coloredMethods; - - return ( - - {label} - - ); + return ; } diff --git a/apps/yaak-client/components/core/Input.tsx b/apps/yaak-client/components/core/Input.tsx index c00632748..e1f8cd343 100644 --- a/apps/yaak-client/components/core/Input.tsx +++ b/apps/yaak-client/components/core/Input.tsx @@ -1,19 +1,24 @@ -import type { EditorView } from "@codemirror/view"; -import type { Color } from "@yaakapp-internal/plugins"; -import { HStack, Icon, type IconProps } from "@yaakapp-internal/ui"; +import { + HStack, + Icon, + type IconProps, + IconTooltip, + Input as BaseUiInput, + type InputHandle, + type InputProps as BaseUiInputProps, + useStateWithDeps, +} from "@yaakapp-internal/ui"; +import type { CodeEditorProps } from "@yaakapp-internal/ui/editor"; import classNames from "classnames"; -import type { ReactNode } from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { createFastMutation } from "../../hooks/useFastMutation"; import { useIsEncryptionEnabled } from "../../hooks/useIsEncryptionEnabled"; -import { useStateWithDeps } from "../../hooks/useStateWithDeps"; import { copyToClipboard } from "../../lib/copy"; import { analyzeTemplate, convertTemplateToInsecure, convertTemplateToSecure, } from "../../lib/encryption"; -import { generateId } from "../../lib/generateId"; import { setupOrConfigureEncryption, withEncryptionEnabled, @@ -25,69 +30,30 @@ import type { EditorProps } from "./Editor/Editor"; import type { GenericCompletion } from "./Editor/genericCompletion"; import { Editor } from "./Editor/LazyEditor"; import { IconButton } from "./IconButton"; -import { IconTooltip } from "./IconTooltip"; -import { Label } from "./Label"; -export type InputProps = Pick< - EditorProps, - | "language" - | "autocomplete" - | "forcedEnvironmentId" - | "forceUpdateKey" - | "disabled" - | "autoFocus" - | "autoSelect" - | "autocompleteVariables" - | "autocompleteFunctions" - | "onKeyDown" - | "readOnly" -> & { - className?: string; - containerClassName?: string; - inputWrapperClassName?: string; - defaultValue?: string | null; - disableObscureToggle?: boolean; - fullHeight?: boolean; - hideLabel?: boolean; - help?: ReactNode; - label: ReactNode; - labelClassName?: string; - labelPosition?: "top" | "left"; - leftSlot?: ReactNode; - multiLine?: boolean; - name?: string; - onBlur?: () => void; - onChange?: (value: string) => void; - onFocus?: () => void; - onPaste?: (value: string) => void; - onPasteOverwrite?: EditorProps["onPasteOverwrite"]; - placeholder?: string; - required?: boolean; - rightSlot?: ReactNode; - /** - * Show a chevron that opens the `autocomplete` options as a menu, so a value can be picked - * without knowing what to type. Opt-in, because plenty of inputs pass `autocomplete` without - * wanting the affordance (the URL bar completes from history, and a chevron there would be - * noise). Renders nothing when there are no options to show. - */ - showOptionsPicker?: boolean; - size?: "2xs" | "xs" | "sm" | "md" | "auto"; - stateKey: EditorProps["stateKey"]; - extraExtensions?: EditorProps["extraExtensions"]; - tint?: Color; - type?: "text" | "password"; - validate?: boolean | ((v: string) => boolean); - wrapLines?: boolean; - setRef?: (h: InputHandle | null) => void; -}; +export type { InputHandle }; -export interface InputHandle { - focus: () => void; - isFocused: () => boolean; - value: () => string; - selectAll: () => void; - dispatch: EditorView["dispatch"]; -} +export type InputProps = Omit< + BaseUiInputProps, + "language" | "extensions" | "renderEditor" | "renderOptionsPicker" +> & + Pick< + EditorProps, + | "language" + | "autocomplete" + | "forcedEnvironmentId" + | "autocompleteVariables" + | "autocompleteFunctions" + | "extraExtensions" + > & { + /** + * Show a chevron that opens the `autocomplete` options as a menu, so a value can be picked + * without knowing what to type. Opt-in, because plenty of inputs pass `autocomplete` without + * wanting the affordance (the URL bar completes from history, and a chevron there would be + * noise). Renders nothing when there are no options to show. + */ + showOptionsPicker?: boolean; + }; export function Input({ type, ...props }: InputProps) { // If it's a password and template functions are supported (ie. secure(...)) then @@ -99,283 +65,47 @@ export function Input({ type, ...props }: InputProps) { } function BaseInput({ - className, - containerClassName, - defaultValue, - disableObscureToggle, - disabled, - forceUpdateKey, - fullHeight, - help, - hideLabel, - inputWrapperClassName, - label, - labelClassName, - labelPosition = "top", - leftSlot, - multiLine, - onBlur, - onChange, - onFocus, - onPaste, - onPasteOverwrite, - placeholder, - readOnly, - required, - rightSlot, + autocomplete, + autocompleteFunctions, + autocompleteVariables, + extraExtensions, + forcedEnvironmentId, + language, showOptionsPicker, - size = "md", - stateKey, - tint, - type = "text", - validate, - wrapLines, - setRef, ...props }: InputProps) { - const [focused, setFocused] = useState(false); - const [obscured, setObscured] = useStateWithDeps(type === "password", [type]); - const [hasChanged, setHasChanged] = useStateWithDeps(false, [forceUpdateKey]); - const editorRef = useRef(null); - const skipNextFocus = useRef(false); - - const handle = useMemo( - () => ({ - focus: () => { - if (editorRef.current == null) return; - const anchor = editorRef.current.state.doc.length; - skipNextFocus.current = true; - editorRef.current.focus(); - editorRef.current.dispatch({ selection: { anchor, head: anchor }, scrollIntoView: true }); - }, - isFocused: () => editorRef.current?.hasFocus ?? false, - value: () => editorRef.current?.state.doc.toString() ?? "", - dispatch: (...args) => { - // oxlint-disable-next-line no-explicit-any - editorRef.current?.dispatch(...(args as any)); - }, - selectAll() { - if (editorRef.current == null) return; - editorRef.current.focus(); - editorRef.current.dispatch({ - selection: { anchor: 0, head: editorRef.current.state.doc.length }, - }); - }, - }), - [], + const renderEditor = useCallback( + (editorProps: CodeEditorProps) => ( + + ), + [ + language, + autocomplete, + autocompleteFunctions, + autocompleteVariables, + extraExtensions, + forcedEnvironmentId, + ], ); - // Replace the whole value with a picked option. Dispatching runs it through the editor's - // onChange, same as typing, so callers see one consistent change path. - const insertOption = useCallback((value: string) => { - const view = editorRef.current; - if (view == null) return; - view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } }); - view.focus(); - }, []); - - const pickerOptions = showOptionsPicker && !disabled ? (props.autocomplete?.options ?? []) : []; - - const setEditorRef = useCallback( - (h: EditorView | null) => { - editorRef.current = h; - setRef?.(handle); - }, - [handle, setRef], - ); - - useEffect(() => { - const fn = () => { - skipNextFocus.current = true; - }; - window.addEventListener("focus", fn); - return () => { - window.removeEventListener("focus", fn); - }; - }, []); - - const handleFocus = useCallback(() => { - if (readOnly) return; - - if (!skipNextFocus.current) { - editorRef.current?.dispatch({ - selection: { anchor: 0, head: editorRef.current.state.doc.length }, - }); - } - - setFocused(true); - onFocus?.(); - skipNextFocus.current = false; - }, [onFocus, readOnly]); - - const handleBlur = useCallback(async () => { - setFocused(false); - // Move selection to the end on blur - const anchor = editorRef.current?.state.doc.length ?? 0; - editorRef.current?.dispatch({ - selection: { anchor, head: anchor }, - }); - onBlur?.(); - }, [onBlur]); - - const id = useRef(`input-${generateId()}`); - const editorClassName = classNames( - className, - "bg-transparent! min-w-0 h-auto w-full focus:outline-hidden placeholder:text-placeholder", - ); - - const isValid = useMemo(() => { - if (required && !validateRequire(defaultValue ?? "")) return false; - if (typeof validate === "boolean") return validate; - if (typeof validate === "function" && !validate(defaultValue ?? "")) return false; - return true; - }, [required, defaultValue, validate]); - - const handleChange = useCallback( - (value: string) => { - onChange?.(value); - setHasChanged(true); - }, - [onChange, setHasChanged], - ); - - const wrapperRef = useRef(null); - - // Submit the nearest form on Enter key press - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key !== "Enter") return; - - const form = wrapperRef.current?.closest("form"); - if (!isValid || form == null) return; - - form?.dispatchEvent(new Event("submit", { cancelable: true, bubbles: true })); - }, - [isValid], - ); + const pickerOptions = showOptionsPicker && !props.disabled ? (autocomplete?.options ?? []) : []; + const renderOptionsPicker = + pickerOptions.length > 0 + ? (insert: (value: string) => void) => ( + + ) + : undefined; return ( -
- - - {tint != null && ( -
- )} - {leftSlot} - - - - {type === "password" && !disableObscureToggle && ( - setObscured((o) => !o)} - /> - )} - {pickerOptions.length > 0 && ( - - )} - {rightSlot} - -
+ ); } @@ -419,10 +149,6 @@ function OptionsPickerDropdown({ ); } -function validateRequire(v: string) { - return v.length > 0; -} - type PasswordFieldType = "text" | "encrypted"; function EncryptionInput({ diff --git a/apps/yaak-client/components/core/KeyValueRow.tsx b/apps/yaak-client/components/core/KeyValueRow.tsx index 75847fcd9..bc9522c91 100644 --- a/apps/yaak-client/components/core/KeyValueRow.tsx +++ b/apps/yaak-client/components/core/KeyValueRow.tsx @@ -1,106 +1,10 @@ -import classNames from "classnames"; -import type { HTMLAttributes, ReactElement, ReactNode } from "react"; -import { CopyIconButton } from "../CopyIconButton"; +import { KeyValueRow as BaseKeyValueRow, type KeyValueRowProps } from "@yaakapp-internal/ui"; +import { copyToClipboard } from "../../lib/copy"; -interface Props { - children: - | ReactElement> - | (ReactElement> | null)[]; - selectable?: boolean; +export function KeyValueRow(props: Omit) { + return ; } -export function KeyValueRows({ children, selectable }: Props) { - const childArray = Array.isArray(children) ? children.filter(Boolean) : [children]; - return ( - - - {childArray.map((child, i) => ( - // oxlint-disable-next-line react/no-array-index-key - {child} - ))} - -
- ); -} - -interface KeyValueRowProps { - label: ReactNode; - children: ReactNode; - rightSlot?: ReactNode; - leftSlot?: ReactNode; - align?: "top" | "middle"; - labelClassName?: string; - labelColor?: "secondary" | "primary" | "info"; - enableCopy?: boolean; - copyText?: string; -} - -export function KeyValueRow({ - label, - children, - rightSlot, - leftSlot, - align = "top", - labelColor = "secondary", - labelClassName, - enableCopy, - copyText, -}: KeyValueRowProps) { - const textToCopy = - copyText ?? - (typeof children === "string" || typeof children === "number" ? `${children}` : null); - const copyTitle = - typeof label === "string" || typeof label === "number" ? `Copy ${label}` : "Copy value"; - const resolvedRightSlot = - rightSlot ?? - (enableCopy && textToCopy != null ? ( - - ) : null); - - return ( - <> - - {label} - - -
- {leftSlot ?? } - {children} - {resolvedRightSlot ? ( -
{resolvedRightSlot}
- ) : ( - - )} -
- - - ); +function copy(text: string) { + copyToClipboard(text, { disableToast: true }); } diff --git a/apps/yaak-client/components/core/Link.tsx b/apps/yaak-client/components/core/Link.tsx index f421b063a..297e205dd 100644 --- a/apps/yaak-client/components/core/Link.tsx +++ b/apps/yaak-client/components/core/Link.tsx @@ -1,4 +1,5 @@ import { Link as RouterLink } from "@tanstack/react-router"; +import { platform } from "@yaakapp-internal/platform"; import classNames from "classnames"; import type { HTMLAttributes } from "react"; import { appInfo } from "../../lib/appInfo"; @@ -33,7 +34,10 @@ export function Link({ href, children, noUnderline, className, ...other }: Props href={finalHref} target="_blank" rel={isYaakLink ? undefined : "noopener noreferrer"} - onClick={(e) => e.preventDefault()} + onClick={(e) => { + e.preventDefault(); + void platform.openUrl(finalHref); + }} className={className} {...other} > diff --git a/apps/yaak-client/components/core/PairEditor.tsx b/apps/yaak-client/components/core/PairEditor.tsx index 58a6e7462..c4e699526 100644 --- a/apps/yaak-client/components/core/PairEditor.tsx +++ b/apps/yaak-client/components/core/PairEditor.tsx @@ -1,971 +1,8 @@ -import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core"; -import { - DndContext, - DragOverlay, - PointerSensor, - pointerWithin, - useDraggable, - useDroppable, - useSensor, - useSensors, -} from "@dnd-kit/core"; -import classNames from "classnames"; -import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { WrappedEnvironmentVariable } from "../../hooks/useEnvironmentVariables"; -import { useRandomKey } from "../../hooks/useRandomKey"; -import { useToggle } from "../../hooks/useToggle"; -import { languageFromContentType } from "../../lib/contentType"; -import { showDialog } from "../../lib/dialog"; -import { computeSideForDragMove, DropMarker } from "@yaakapp-internal/ui"; -import { showPrompt } from "../../lib/prompt"; -import { SelectFile } from "../SelectFile"; -import { Button } from "./Button"; -import { Checkbox } from "./Checkbox"; -import type { DropdownItem } from "./Dropdown"; -import { Dropdown } from "./Dropdown"; -import type { EditorProps } from "./Editor/Editor"; -import type { GenericCompletionConfig } from "./Editor/genericCompletion"; -import { Editor } from "./Editor/LazyEditor"; -import { Icon } from "@yaakapp-internal/ui"; -import { IconButton } from "./IconButton"; -import type { InputHandle, InputProps } from "./Input"; -import { Input } from "./Input"; -import { ensurePairId } from "./PairEditor.util"; -import type { RadioDropdownItem } from "./RadioDropdown"; -import { RadioDropdown } from "./RadioDropdown"; -import { platform } from "@yaakapp-internal/platform"; - -export interface PairEditorHandle { - /** - * Focus a row's name field once it's able to take focus. Focus can't land immediately when the - * row isn't mounted yet or the editor is hidden — eg. sitting in a tab that's still becoming - * active — so this retries for up to ~1s. A newer focus request cancels a pending one. - */ - focusName(id: string): void; - /** Focus a row's value field. See {@link PairEditorHandle.focusName} for timing. */ - focusValue(id: string): void; -} - -/** ~1s at 60fps, plenty for a tab switch to land without spinning forever if it never does */ -const MAX_FOCUS_ATTEMPTS = 60; - -export type PairEditorProps = { - allowFileValues?: boolean; - allowMultilineValues?: boolean; - className?: string; - forcedEnvironmentId?: string; - forceUpdateKey?: string; - nameAutocomplete?: GenericCompletionConfig; - nameAutocompleteFunctions?: boolean; - nameAutocompleteVariables?: boolean; - namePlaceholder?: string; - nameValidate?: InputProps["validate"]; - noScroll?: boolean; - onChange: (pairs: PairWithId[]) => void; - pairs: EditablePair[]; - stateKey: InputProps["stateKey"]; - setRef?: (n: PairEditorHandle) => void; - valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined; - valueAutocompleteFunctions?: boolean; - valueAutocompleteVariables?: boolean | "environment"; - valuePlaceholder?: string; - valueType?: InputProps["type"] | ((pair: Pair) => InputProps["type"]); - valueValidate?: InputProps["validate"]; -}; - -export type Pair = { - id?: string; - enabled?: boolean; - name: string; - value: string; - contentType?: string; - filename?: string; - isFile?: boolean; -}; - -export type PairWithId = Pair & { - id: string; -}; - -/** - * A pair as handed to the editor. Adds behaviour that only the editor cares about, so the plain - * `Pair` stays the shape that gets written to models. - */ -export type EditablePair = Pair & { - /** - * When set, name edits are held until the field blurs and then committed through this, instead - * of calling `onChange` on every keystroke. Return false to reject the new name, which reverts - * the field. For names that can't be written directly, like a URL path placeholder that lives - * in the URL itself. - */ - commitName?: (name: string) => boolean; -}; - -type EditablePairWithId = EditablePair & { - id: string; -}; - -/** Strip the editor-only fields, so they can never reach a model write */ -function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): PairWithId { - return pair; -} - -/** Max number of pairs to show before prompting the user to reveal the rest */ -const MAX_INITIAL_PAIRS = 30; - -// 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 ; -} - -function PairEditorInner({ - allowFileValues, - allowMultilineValues, - className, - forcedEnvironmentId, - forceUpdateKey, - nameAutocomplete, - nameAutocompleteFunctions, - nameAutocompleteVariables, - namePlaceholder, - nameValidate, - noScroll, - onChange, - pairs: originalPairs, - stateKey, - valueAutocomplete, - valueAutocompleteFunctions, - valueAutocompleteVariables, - valuePlaceholder, - valueType, - valueValidate, - setRef, -}: PairEditorProps) { - const [hoveredIndex, setHoveredIndex] = useState(null); - const [isDragging, setIsDragging] = useState(null); - const [pairs, setPairs] = useState([]); - const [showAll, toggleShowAll] = useToggle(false); - // NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If - // we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale. - const [localForceUpdateKey, regenerateLocalForceUpdateKey] = useRandomKey(); - - const rowsRef = useRef>({}); - - const pendingFocusFrame = useRef(null); - useEffect( - () => () => { - if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current); - }, - [], - ); - - const focusWhenReady = useCallback((id: string, field: "name" | "value") => { - if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current); - - let attemptsLeft = MAX_FOCUS_ATTEMPTS; - const attempt = () => { - pendingFocusFrame.current = null; - const row = rowsRef.current[id]; - const landed = field === "name" ? row?.focusName() : row?.focusValue(); - if (landed || --attemptsLeft <= 0) return; - pendingFocusFrame.current = requestAnimationFrame(attempt); - }; - attempt(); - }, []); - - const handle = useMemo( - () => ({ - focusName(id: string) { - focusWhenReady(id, "name"); - }, - focusValue(id: string) { - focusWhenReady(id, "value"); - }, - }), - [focusWhenReady], - ); - - const initPairEditorRow = useCallback( - (id: string, n: RowHandle | null) => { - const isLast = id === pairs[pairs.length - 1]?.id; - if (isLast) return; // Never add the last pair - - rowsRef.current[id] = n; - const validHandles = Object.values(rowsRef.current).filter((v) => v != null); - - // Use >= because more might be added if an ID of one changes (eg. editing placeholder in URL regenerates fresh pairs every keystroke) - const ready = validHandles.length >= pairs.length - 1; - if (ready) { - setRef?.(handle); - } - }, - [handle, pairs, setRef], - ); - - // oxlint-disable-next-line react-hooks/exhaustive-deps -- Only care about forceUpdateKey - useEffect(() => { - // Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs) - const newPairs: EditablePairWithId[] = []; - for (let i = 0; i < originalPairs.length; i++) { - const p = originalPairs[i]; - if (!p) continue; // Make TS happy - if (isPairEmpty(p)) continue; - newPairs.push(ensurePairId(p)); - } - - // When the reset holds the exact same rows (eg. renaming a URL path placeholder, which keeps - // every row id), swap the data in without rebuilding the row editors. Unfocused inputs re-seed - // themselves when `defaultValue` changes, and a rebuild would drop the user's focus and - // selection — like tabbing from a placeholder's name into its value. - const trailingPair = pairs[pairs.length - 1]; - const sameRows = - trailingPair != null && - isPairEmpty(trailingPair) && - pairs.length === newPairs.length + 1 && - newPairs.every((p, i) => p.id === pairs[i]?.id); - if (sameRows) { - setPairs([...newPairs, trailingPair]); - return; - } - - // Add empty last pair if there is none - const lastPair = newPairs[newPairs.length - 1]; - if (lastPair == null || !isPairEmpty(lastPair)) { - newPairs.push(emptyPair()); - } - - setPairs(newPairs); - regenerateLocalForceUpdateKey(); - }, [forceUpdateKey]); - - const setPairsAndSave = useCallback( - (fn: (pairs: EditablePairWithId[]) => EditablePairWithId[]) => { - setPairs((oldPairs) => { - const pairs = fn(oldPairs); - onChange(pairs.map(toPairData)); - return pairs; - }); - }, - [onChange], - ); - - const handleChange = useCallback( - (pair: EditablePairWithId) => - setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))), - [setPairsAndSave], - ); - - const handleDelete = useCallback( - (pair: Pair, focusPrevious: boolean) => { - if (focusPrevious) { - const index = pairs.findIndex((p) => p.id === pair.id); - const id = pairs[index - 1]?.id ?? null; - rowsRef.current[id ?? "n/a"]?.focusName(); - } - return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id)); - }, - [setPairsAndSave, pairs], - ); - - const handleFocusName = useCallback( - (pair: Pair) => { - const isLast = pair.id === pairs[pairs.length - 1]?.id; - if (isLast) setPairs([...pairs, emptyPair()]); - }, - [pairs], - ); - - const handleFocusValue = useCallback( - (pair: Pair) => { - const isLast = pair.id === pairs[pairs.length - 1]?.id; - if (isLast) setPairs([...pairs, emptyPair()]); - }, - [pairs], - ); - - const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); - - // dnd-kit: show the “between rows” marker while hovering - const onDragMove = useCallback( - (e: DragMoveEvent) => { - const overId = e.over?.id as string | undefined; - if (!overId) return setHoveredIndex(null); - - const overPair = pairs.find((p) => p.id === overId); - if (overPair == null) return setHoveredIndex(null); - - const side = computeSideForDragMove(overPair.id, e); - const overIndex = pairs.findIndex((p) => p.id === overId); - const hoveredIndex = overIndex + (side === "before" ? 0 : 1); - - setHoveredIndex(hoveredIndex); - }, - [pairs], - ); - - const onDragStart = useCallback( - (e: DragStartEvent) => { - const pair = pairs.find((p) => p.id === e.active.id); - setIsDragging(pair ?? null); - }, - [pairs], - ); - - const onDragCancel = useCallback(() => setIsDragging(null), []); - - const onDragEnd = useCallback( - (e: DragEndEvent) => { - setIsDragging(null); - setHoveredIndex(null); - const activeId = e.active.id as string | undefined; - const overId = e.over?.id as string | undefined; - if (!activeId || !overId) return; - - const from = pairs.findIndex((p) => p.id === activeId); - const baseTo = pairs.findIndex((p) => p.id === overId); - const to = hoveredIndex ?? (baseTo === -1 ? from : baseTo); - - if (from !== -1 && to !== -1 && from !== to) { - setPairsAndSave((ps) => { - const next = [...ps]; - const [moved] = next.splice(from, 1); - if (moved === undefined) return ps; // Make TS happy - next.splice(to > from ? to - 1 : to, 0, moved); - return next; - }); - } - }, - [pairs, hoveredIndex, setPairsAndSave], - ); - - return ( -
-
- - {pairs.map((p, i) => { - if (!showAll && i > MAX_INITIAL_PAIRS) return null; - - const isLast = i === pairs.length - 1; - return ( - - {hoveredIndex === i && } - - - ); - })} - {!showAll && pairs.length > MAX_INITIAL_PAIRS && ( - - )} - - {isDragging && ( - - )} - - -
- -
{ - e.preventDefault(); - e.stopPropagation(); - }} - /> -
- ); -} - -type PairEditorRowProps = { - className?: string; - pair: EditablePairWithId; - forceFocusNamePairId?: string | null; - forceFocusValuePairId?: string | null; - onChange?: (pair: EditablePairWithId) => void; - onDelete?: (pair: EditablePairWithId, focusPrevious: boolean) => void; - onFocusName?: (pair: EditablePairWithId) => void; - onFocusValue?: (pair: EditablePairWithId) => void; - onSubmit?: (pair: EditablePairWithId) => void; - isLast?: boolean; - disabled?: boolean; - disableDrag?: boolean; - index: number; - isDraggingGlobal?: boolean; - setRef?: (id: string, n: RowHandle | null) => void; -} & Pick< +export type { + EditablePair, + Pair, + PairEditorHandle, PairEditorProps, - | "allowFileValues" - | "allowMultilineValues" - | "forcedEnvironmentId" - | "forceUpdateKey" - | "nameAutocomplete" - | "nameAutocompleteVariables" - | "namePlaceholder" - | "nameValidate" - | "nameAutocompleteFunctions" - | "stateKey" - | "valueAutocomplete" - | "valueAutocompleteFunctions" - | "valueAutocompleteVariables" - | "valuePlaceholder" - | "valueType" - | "valueValidate" ->; - -interface RowHandle { - focusName(): boolean; - focusValue(): boolean; -} - -export function PairEditorRow({ - allowFileValues, - allowMultilineValues, - className, - disableDrag, - disabled, - forceUpdateKey, - forcedEnvironmentId, - index, - isLast, - nameAutocomplete, - nameAutocompleteFunctions, - nameAutocompleteVariables, - namePlaceholder, - nameValidate, - isDraggingGlobal, - onChange, - onDelete, - onFocusName, - onFocusValue, - pair, - stateKey, - valueAutocomplete, - valueAutocompleteFunctions, - valueAutocompleteVariables, - valuePlaceholder, - valueType, - valueValidate, - setRef, -}: PairEditorRowProps) { - const nameInputRef = useRef(null); - const valueInputRef = useRef(null); - const handle = useRef({ - focusName() { - nameInputRef.current?.focus(); - return nameInputRef.current?.isFocused() ?? false; - }, - focusValue() { - valueInputRef.current?.focus(); - return valueInputRef.current?.isFocused() ?? false; - }, - }); - - const initNameInputRef = useCallback( - (n: InputHandle | null) => { - nameInputRef.current = n; - if (nameInputRef.current && valueInputRef.current) { - setRef?.(pair.id, handle.current); - } - }, - [pair.id, setRef], - ); - - const initValueInputRef = useCallback( - (n: InputHandle | null) => { - valueInputRef.current = n; - if (nameInputRef.current && valueInputRef.current) { - setRef?.(pair.id, handle.current); - } - }, - [pair.id, setRef], - ); - - const handleFocusName = useCallback(() => onFocusName?.(pair), [onFocusName, pair]); - const handleFocusValue = useCallback(() => onFocusValue?.(pair), [onFocusValue, pair]); - const handleDelete = useCallback(() => onDelete?.(pair, false), [onDelete, pair]); - - const handleChangeEnabled = useMemo( - () => (enabled: boolean) => onChange?.({ ...pair, enabled }), - [onChange, pair], - ); - - // The name being typed into a deferred-commit field, before it's committed or reverted - const pendingName = useRef(null); - - const handleChangeName = useMemo( - () => (name: string) => { - // Keep the edit local until commit. Writing on every keystroke would reset the editor from - // beneath the cursor, since the pairs are derived from the name being edited. - if (pair.commitName != null) pendingName.current = name; - else onChange?.({ ...pair, name }); - }, - [onChange, pair], - ); - - const revertName = useCallback(() => { - const nameInput = nameInputRef.current; - if (nameInput != null) { - const changes = { from: 0, to: nameInput.value().length, insert: pair.name }; - nameInput.dispatch({ changes }); - } - pendingName.current = null; - }, [pair.name]); - - const handleBlurName = useCallback(() => { - if (pair.commitName == null) return; - - const name = pendingName.current; - pendingName.current = null; - if (name == null || name === pair.name) return; - if (!pair.commitName(name)) revertName(); - }, [pair, revertName]); - - const handleChangeValueText = useMemo( - () => (value: string) => onChange?.({ ...pair, value, isFile: false }), - [onChange, pair], - ); - - const handleChangeValueFile = useMemo( - () => - ({ filePath }: { filePath: string | null }) => - onChange?.({ ...pair, value: filePath ?? "", isFile: true }), - [onChange, pair], - ); - - const handleChangeValueContentType = useMemo( - () => (contentType: string) => onChange?.({ ...pair, contentType }), - [onChange, pair], - ); - - const handleChangeValueFilename = useMemo( - () => (filename: string) => onChange?.({ ...pair, filename }), - [onChange, pair], - ); - - const handleEditMultiLineValue = useCallback( - () => - showDialog({ - id: "pair-edit-multiline", - size: "dynamic", - title: <>Edit {pair.name}, - render: ({ hide }) => ( - - ), - }), - [handleChangeValueText, pair.contentType, pair.name, pair.value], - ); - - const defaultItems = useMemo( - (): DropdownItem[] => [ - { - label: "Edit Multi-line", - onSelect: handleEditMultiLineValue, - hidden: !allowMultilineValues, - }, - { - label: "Delete", - onSelect: handleDelete, - color: "danger", - }, - ], - [allowMultilineValues, handleDelete, handleEditMultiLineValue], - ); - - const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: pair.id }); - const { setNodeRef: setDroppableRef } = useDroppable({ id: pair.id }); - - // Filter out the current pair name - const valueAutocompleteVariablesFiltered = useMemo(() => { - if (valueAutocompleteVariables === "environment") { - return (v: WrappedEnvironmentVariable): boolean => v.variable.name !== pair.name; - } - return valueAutocompleteVariables; - }, [pair.name, valueAutocompleteVariables]); - - const handleSetRef = useCallback( - (n: HTMLDivElement | null) => { - setDraggableRef(n); - setDroppableRef(n); - }, - [setDraggableRef, setDroppableRef], - ); - - // Skip the trailing placeholder row. It exists to start a new pair, so a picker there would - // imply it's already a real row. `Input` handles the rest, including having no options to show. - const showOptionsPicker = !isLast; - - return ( -
- - {!isLast && !disableDrag ? ( -
- -
- ) : ( - - )} -
- -
- {pair.isFile ? ( - - ) : pair.value.includes("\n") ? ( - - ) : ( - - )} -
-
- {allowFileValues ? ( - - ) : ( - - - - )} -
- ); -} - -const fileItems: RadioDropdownItem[] = [ - { label: "Text", value: "text" }, - { label: "File", value: "file" }, -]; - -function FileActionsDropdown({ - pair, - onChangeFile, - onChangeText, - onChangeContentType, - onChangeFilename, - onDelete, - editMultiLine, -}: { - pair: Pair; - onChangeFile: ({ filePath }: { filePath: string | null }) => void; - onChangeText: (text: string) => void; - onChangeContentType: (contentType: string) => void; - onChangeFilename: (filename: string) => void; - onDelete: () => void; - editMultiLine: () => void; -}) { - const onChange = useCallback( - (v: string) => { - if (v === "file") onChangeFile({ filePath: "" }); - else onChangeText(""); - }, - [onChangeFile, onChangeText], - ); - - const itemsAfter = useMemo( - () => [ - { - label: "Edit Multi-Line", - leftSlot: , - hidden: pair.isFile, - onSelect: editMultiLine, - }, - { - label: "Set Content-Type", - leftSlot: , - onSelect: async () => { - const contentType = await showPrompt({ - id: "content-type", - title: "Override Content-Type", - label: "Content-Type", - required: false, - placeholder: "text/plain", - defaultValue: pair.contentType ?? "", - confirmText: "Set", - description: "Leave blank to auto-detect", - }); - if (contentType == null) return; - onChangeContentType(contentType); - }, - }, - { - label: "Set File Name", - leftSlot: , - onSelect: async () => { - console.log("PAIR", pair); - const defaultFilename = await platform.files.basename(pair.value ?? ""); - const filename = await showPrompt({ - id: "filename", - title: "Override Filename", - label: "Filename", - required: false, - placeholder: defaultFilename ?? "myfile.png", - defaultValue: pair.filename, - confirmText: "Set", - description: "Leave blank to use the name of the selected file", - }); - if (filename == null) return; - onChangeFilename(filename); - }, - }, - { - label: "Unset File", - leftSlot: , - hidden: pair.isFile, - onSelect: async () => { - onChangeFile({ filePath: null }); - }, - }, - { - label: "Delete", - onSelect: onDelete, - variant: "danger", - leftSlot: , - color: "danger", - }, - ], - [ - editMultiLine, - onChangeContentType, - onChangeFile, - onDelete, - pair.contentType, - pair.isFile, - onChangeFilename, - pair.filename, - pair, - ], - ); - - return ( - - - - ); -} - -function emptyPair(): EditablePairWithId { - return ensurePairId({ enabled: true, name: "", value: "" }); -} - -function isPairEmpty(pair: Pair): boolean { - return !pair.name && !pair.value; -} - -function MultilineEditDialog({ - defaultValue, - contentType, - onChange, - hide, -}: { - defaultValue: string; - contentType: string | null; - onChange: (value: string) => void; - hide: () => void; -}) { - const [value, setValue] = useState(defaultValue); - const language = languageFromContentType(contentType, value); - return ( -
- -
- -
-
- ); -} + PairWithId, +} from "@yaakapp-internal/ui"; +export { PairEditor, PairEditorRow } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/core/PairEditor.util.tsx b/apps/yaak-client/components/core/PairEditor.util.tsx index 27feb01ea..f2db8c728 100644 --- a/apps/yaak-client/components/core/PairEditor.util.tsx +++ b/apps/yaak-client/components/core/PairEditor.util.tsx @@ -1,10 +1 @@ -import { generateId } from "../../lib/generateId"; -import type { Pair, PairWithId } from "./PairEditor"; - -// NOTE: Generic so callers keep whatever they passed in (eg. an EditablePair stays editable) -export function ensurePairId(p: T): T & PairWithId { - if (typeof p.id === "string") { - return p as T & PairWithId; - } - return { ...p, id: p.id ?? generateId() }; -} +export { ensurePairId } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/core/PairOrBulkEditor.tsx b/apps/yaak-client/components/core/PairOrBulkEditor.tsx index 977c7dabd..861fd0e92 100644 --- a/apps/yaak-client/components/core/PairOrBulkEditor.tsx +++ b/apps/yaak-client/components/core/PairOrBulkEditor.tsx @@ -1,38 +1 @@ -import classNames from "classnames"; -import { useKeyValue } from "../../hooks/useKeyValue"; -import { BulkPairEditor } from "./BulkPairEditor"; -import { IconButton } from "./IconButton"; -import type { PairEditorProps } from "./PairEditor"; -import { PairEditor } from "./PairEditor"; - -interface Props extends PairEditorProps { - preferenceName: string; - forcedEnvironmentId?: string; -} - -export function PairOrBulkEditor({ preferenceName, ...props }: Props) { - const { value: useBulk, set: setUseBulk } = useKeyValue({ - namespace: "global", - key: ["bulk_edit", preferenceName], - fallback: false, - }); - - return ( -
- {useBulk ? : } -
- setUseBulk((b) => !b)} - icon={useBulk ? "table" : "file_code"} - /> -
-
- ); -} +export { PairOrBulkEditor } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/core/PathList.tsx b/apps/yaak-client/components/core/PathList.tsx new file mode 100644 index 000000000..9aebc699e --- /dev/null +++ b/apps/yaak-client/components/core/PathList.tsx @@ -0,0 +1,124 @@ +import { Icon, type IconProps } from "@yaakapp-internal/ui"; +import classNames from "classnames"; +import { forwardRef, type ReactNode } from "react"; +import { Dropdown, type DropdownItem } from "./Dropdown"; +import { IconButton } from "./IconButton"; + +export interface PathListItem { + path: string; + icon: IconProps["icon"]; + /** Replaces the parent-path line under the name */ + subtitle?: ReactNode; + failed?: boolean; +} + +interface Props { + items: PathListItem[]; + disabled?: boolean; + onRemove: (path: string) => void; + addItems: DropdownItem[]; + addLabel: string; + addHint?: string; + hovering?: boolean; + /** Rows rendered between the items and the add row, such as an inline input */ + children?: ReactNode; +} + +/** Selected files, folders, or URLs as cards, with a dashed add row that opens a menu. */ +export const PathList = forwardRef(function PathList( + { items, disabled, onRemove, addItems, addLabel, addHint, hovering, children }, + ref, +) { + return ( +
+ {items.map((item) => { + const { name, parent } = splitPath(item.path); + return ( +
+ +
+
{name}
+
+ {item.subtitle ?? + (parent !== "" && {parent})} +
+
+ onRemove(item.path)} + /> +
+ ); + })} + {children} + + + +
+ ); +}); + +/** The last segment as the name and everything before it, shortened, as the parent. */ +export function splitPath(path: string): { name: string; parent: string } { + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) { + try { + const url = new URL(path); + const segments = url.pathname.split("/").filter(Boolean); + const name = segments.at(-1) ?? url.host; + const parent = segments.length > 0 ? `${url.host}/${segments.slice(0, -1).join("/")}` : ""; + return { name, parent: parent.replace(/\/$/, "") }; + } catch { + return { name: path, parent: "" }; + } + } + const parts = path.split(/[/\\]/).filter(Boolean); + const name = parts.at(-1) ?? path; + const dirs = parts.slice(0, -1); + const parent = dirs.length > 3 ? `…/${dirs.slice(-3).join("/")}` : dirs.join("/"); + return { name, parent }; +} diff --git a/apps/yaak-client/components/core/PillButton.tsx b/apps/yaak-client/components/core/PillButton.tsx index ba2b64251..9b1bcf06a 100644 --- a/apps/yaak-client/components/core/PillButton.tsx +++ b/apps/yaak-client/components/core/PillButton.tsx @@ -1,14 +1 @@ -import classNames from "classnames"; -import type { ButtonProps } from "./Button"; -import { Button } from "./Button"; - -export function PillButton({ className, ...props }: ButtonProps) { - return ( - - - + ); } diff --git a/apps/yaak-client/components/core/RadioDropdown.tsx b/apps/yaak-client/components/core/RadioDropdown.tsx index a1a642e90..ed5bdd00f 100644 --- a/apps/yaak-client/components/core/RadioDropdown.tsx +++ b/apps/yaak-client/components/core/RadioDropdown.tsx @@ -1,69 +1,2 @@ -import type { ReactNode } from "react"; -import { useMemo } from "react"; -import type { DropdownItem, DropdownItemSeparator, DropdownProps } from "./Dropdown"; -import { Dropdown } from "./Dropdown"; -import { Icon } from "@yaakapp-internal/ui"; - -export type RadioDropdownItem = - | { - type?: "default"; - label: ReactNode; - shortLabel?: ReactNode; - value: T; - rightSlot?: ReactNode; - } - | DropdownItemSeparator; - -export interface RadioDropdownProps { - value: T; - onChange: (value: T) => void; - itemsBefore?: DropdownItem[]; - items: RadioDropdownItem[]; - itemsAfter?: DropdownItem[]; - children: DropdownProps["children"]; -} - -export function RadioDropdown({ - value, - items, - itemsAfter, - itemsBefore, - onChange, - children, -}: RadioDropdownProps) { - const dropdownItems = useMemo( - () => [ - ...((itemsBefore - ? [ - ...itemsBefore, - { - type: "separator", - hidden: itemsBefore[itemsBefore.length - 1]?.type === "separator", - }, - ] - : []) as DropdownItem[]), - ...items.map((item) => { - if (item.type === "separator") { - return item; - } - return { - key: item.value, - label: item.label, - rightSlot: item.rightSlot, - onSelect: () => onChange(item.value), - leftSlot: , - } as DropdownItem; - }), - ...((itemsAfter - ? [{ type: "separator", hidden: itemsAfter[0]?.type === "separator" }, ...itemsAfter] - : []) as DropdownItem[]), - ], - [itemsBefore, items, itemsAfter, value, onChange], - ); - - return ( - - {children} - - ); -} +export type { RadioDropdownItem, RadioDropdownProps } from "@yaakapp-internal/ui"; +export { RadioDropdown } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/core/SegmentedControl.tsx b/apps/yaak-client/components/core/SegmentedControl.tsx index 4b6022f52..b2ab147f5 100644 --- a/apps/yaak-client/components/core/SegmentedControl.tsx +++ b/apps/yaak-client/components/core/SegmentedControl.tsx @@ -1,12 +1,10 @@ import type { IconProps } from "@yaakapp-internal/ui"; -import { HStack } from "@yaakapp-internal/ui"; +import { HStack, useStateWithDeps, IconTooltip, Label } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { type ReactNode, useRef } from "react"; -import { useStateWithDeps } from "../../hooks/useStateWithDeps"; -import { generateId } from "../../lib/generateId"; import { Button } from "./Button"; import { IconButton, type IconButtonProps } from "./IconButton"; -import { Label } from "./Label"; +import { generateId } from "@yaakapp-internal/lib/generateId"; interface Props { options: { value: T; label: string; icon?: IconProps["icon"] }[]; @@ -36,11 +34,15 @@ export function SegmentedControl({ const containerRef = useRef(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 ? : null; + return (
- } - {...btnProps} - > - {option && "shortLabel" in option && option.shortLabel - ? option.shortLabel - : (option?.label ?? "Unknown")} - - - ); - } - return ( - - ); - })(); - - // Apply drag handlers to wrapper, not button - const wrapperProps = reorderable && !overlay ? { ...attributes, ...listeners } : {}; - - return ( -
- {buttonContent} -
- ); -} - -interface TabContentProps { - value: string; - children: ReactNode; - className?: string; -} - -export const TabContent = memo(function TabContent({ - value, - children, - className, -}: TabContentProps) { - return ( - -
- {children} -
-
- ); +export const TabContent = memo(function TabContent(props: Omit) { + return ; }); /** diff --git a/apps/yaak-client/components/core/Toast.tsx b/apps/yaak-client/components/core/Toast.tsx index b851a993f..c8c06785c 100644 --- a/apps/yaak-client/components/core/Toast.tsx +++ b/apps/yaak-client/components/core/Toast.tsx @@ -1,148 +1,2 @@ -import type { ShowToastRequest } from "@yaakapp-internal/plugins"; -import { Icon, type IconProps, VStack } from "@yaakapp-internal/ui"; -import classNames from "classnames"; -import * as m from "motion/react-m"; -import type { ReactNode } from "react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { useKey } from "react-use"; -import { IconButton } from "./IconButton"; - -export interface ToastProps { - children: ReactNode; - open: boolean; - onClose: () => void; - className?: string; - timeout: number | null; - action?: (args: { hide: () => void }) => ReactNode; - icon?: ShowToastRequest["icon"] | null; - color?: ShowToastRequest["color"]; - // Grow with the content (up to the viewport) instead of scrolling internally - // past the default max height - dynamicHeight?: boolean; - // Hide the close button, for toasts that render their own dismiss action. - // Escape still closes the toast - hideDismiss?: boolean; -} - -const ICONS: Record, IconProps["icon"] | null> = { - custom: null, - danger: "alert_triangle", - info: "info", - notice: "alert_triangle", - primary: "info", - secondary: "info", - success: "check_circle", - warning: "alert_triangle", -}; - -export function Toast({ - children, - open, - onClose, - timeout, - action, - icon, - color, - dynamicHeight, - hideDismiss, -}: ToastProps) { - const onCloseRef = useRef(onClose); - const timeoutRef = useRef | null>(null); - const [autoHideCanceled, setAutoHideCanceled] = useState(false); - - useEffect(() => { - onCloseRef.current = onClose; - }, [onClose]); - - const cancelAutoHide = useCallback(() => { - if (timeoutRef.current == null) return; - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - setAutoHideCanceled(true); - }, []); - - useEffect(() => { - if (!open || timeout == null || autoHideCanceled) return; - - timeoutRef.current = setTimeout(() => { - timeoutRef.current = null; - onCloseRef.current(); - }, timeout); - - return () => { - if (timeoutRef.current == null) return; - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - }; - }, [autoHideCanceled, open, timeout]); - - useKey( - "Escape", - () => { - if (!open) return; - onClose(); - }, - {}, - [open], - ); - - const toastIcon = icon === null ? null : (icon ?? (color && color in ICONS && ICONS[color])); - - return ( - -
-
- {toastIcon && } - -
{children}
- {action?.({ hide: onClose })} -
-
- - {!hideDismiss && ( - - )} - - {timeout != null && !autoHideCanceled && ( -
- -
- )} -
-
- ); -} +export type { ToastProps } from "@yaakapp-internal/ui"; +export { Toast } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/git/BranchSelectionDialog.tsx b/apps/yaak-client/components/git/BranchSelectionDialog.tsx deleted file mode 100644 index 0a3be09c9..000000000 --- a/apps/yaak-client/components/git/BranchSelectionDialog.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { HStack, VStack } from "@yaakapp-internal/ui"; -import { useState } from "react"; -import { Button } from "../core/Button"; -import { Select } from "../core/Select"; - -interface Props { - branches: string[]; - onCancel: () => void; - onSelect: (branch: string) => void; - selectText: string; -} - -export function BranchSelectionDialog({ branches, onCancel, onSelect, selectText }: Props) { - const [branch, setBranch] = useState("__NONE__"); - return ( - { - e.preventDefault(); - onSelect(branch); - }} - > - - -
- } - rightSlot={ -
- -
- } - /> -
, - ); - } else { - nodes.push( - , - ); - } - - return nodes; - }, [ - canFilter, - footerActions, - filter, - filterResult?.isPending, - resultError, - isSearching, - language, - handleFilterKeyDown, - ]); - - const formattedBody = useFormatText({ text, language, pretty: pretty ?? false }); - if (formattedBody == null) { - return null; - } - - let body: string; - if (appliedFilter) { - if (resultError) { - body = ""; - } else { - body = filterResult?.data != null ? filterResult.data : ""; - } - } else { - body = formattedBody; - } - - // Decode unicode sequences in the text to readable characters - if (language === "json" && pretty) { - body = decodeUnicodeLiterals(body); - body = body.replace(/\\\//g, "/"); // Hide unnecessary escaping of '/' by some older frameworks - } +export const textViewerExtensions = [hyperlink]; +export function TextViewer( + props: Omit, +) { return ( -
- {appliedFilter && filter != null ? ( - filter.replaceFilter("")} - /> - ) : ( - - )} - -
+ ); } -/** - * Shows what's actually filtering the body, which the filter box below can't convey - * once it holds an edited expression that hasn't been applied yet. - */ -function AppliedFilterBar({ - filter, - error, - onClear, -}: { - filter: string; - error: boolean; - onClear: () => void; -}) { - return ( - - - - - Response filtered by {filter} - {error && " (invalid expression)"} - - - - - ); -} - -/** Convert \uXXXX to actual Unicode characters */ -function decodeUnicodeLiterals(text: string): string { - return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => { - const charCode = Number.parseInt(hex, 16); - return String.fromCharCode(charCode); - }); +export async function formatText( + text: string, + language: CodeEditorProps["language"], +): Promise { + if (language === "json") { + return tryFormatJson(text); + } + if (language === "xml" || language === "html") { + return tryFormatXml(text); + } + return text; } diff --git a/apps/yaak-client/components/responseViewers/pdfWorker.ts b/apps/yaak-client/components/responseViewers/pdfWorker.ts new file mode 100644 index 000000000..25ded5e42 --- /dev/null +++ b/apps/yaak-client/components/responseViewers/pdfWorker.ts @@ -0,0 +1,3 @@ +// The worker has its own globals, so it needs the polyfills before PDF.js evaluates +import "../../polyfills"; +import "pdfjs-dist/build/pdf.worker.min.mjs"; diff --git a/apps/yaak-client/hooks/useAuthTab.tsx b/apps/yaak-client/hooks/useAuthTab.tsx index e094b6e52..871a4d915 100644 --- a/apps/yaak-client/hooks/useAuthTab.tsx +++ b/apps/yaak-client/hooks/useAuthTab.tsx @@ -1,10 +1,9 @@ import type { Folder } from "@yaakapp-internal/models"; import { modelTypeLabel, patchModel } from "@yaakapp-internal/models"; -import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui"; +import { HStack, Icon, InlineCode, IconTooltip } from "@yaakapp-internal/ui"; import { useMemo } from "react"; import { openFolderSettings } from "../commands/openFolderSettings"; import { openWorkspaceSettings } from "../commands/openWorkspaceSettings"; -import { IconTooltip } from "../components/core/IconTooltip"; import type { RadioDropdownProps } from "../components/core/RadioDropdown"; import type { TabItem } from "../components/core/Tabs/Tabs"; import { capitalize } from "../lib/capitalize"; diff --git a/apps/yaak-client/hooks/useClickOutside.ts b/apps/yaak-client/hooks/useClickOutside.ts index 665ddc72a..ab8a10c2c 100644 --- a/apps/yaak-client/hooks/useClickOutside.ts +++ b/apps/yaak-client/hooks/useClickOutside.ts @@ -1,41 +1 @@ -import type { RefObject } from "react"; -import { useEffect, useRef } from "react"; - -/** - * Get notified when a mouse click happens outside the target ref - * @param ref The element to be notified when a mouse click happens outside it - * @param onClickAway - * @param ignored Optional outside element to ignore (useful for dropdown triggers) - */ -export function useClickOutside( - ref: RefObject, - onClickAway: (event: MouseEvent) => void, - ignored?: RefObject, -) { - const savedCallback = useRef(onClickAway); - - useEffect(() => { - savedCallback.current = onClickAway; - }, [onClickAway]); - - useEffect(() => { - const handler = (event: MouseEvent) => { - if (ref.current == null || !(event.target instanceof HTMLElement)) { - return; - } - const isIgnored = ignored?.current?.contains(event.target); - const clickedOutside = !ref.current.contains(event.target); - if (!isIgnored && clickedOutside) { - savedCallback.current(event); - } - }; - // NOTE: We're using mousedown instead of click to handle some edge cases like when a context - // menu is open with the ctrl key. - document.addEventListener("mousedown", handler, { capture: true }); - document.addEventListener("contextmenu", handler, { capture: true }); - return () => { - document.removeEventListener("mousedown", handler); - document.removeEventListener("contextmenu", handler); - }; - }, [ignored, ref]); -} +export { useClickOutside } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/hooks/useCreateDropdownItems.tsx b/apps/yaak-client/hooks/useCreateDropdownItems.tsx index 795432c0d..f8c0cc0e8 100644 --- a/apps/yaak-client/hooks/useCreateDropdownItems.tsx +++ b/apps/yaak-client/hooks/useCreateDropdownItems.tsx @@ -6,10 +6,10 @@ import { createFolder } from "../commands/commands"; import type { DropdownItem } from "../components/core/Dropdown"; import { Icon } from "@yaakapp-internal/ui"; import { createRequestAndNavigate } from "../lib/createRequestAndNavigate"; -import { generateId } from "../lib/generateId"; import { BODY_TYPE_GRAPHQL } from "../lib/model_util"; import { activeRequestAtom } from "./useActiveRequest"; import { activeWorkspaceIdAtom } from "./useActiveWorkspace"; +import { generateId } from "@yaakapp-internal/lib/generateId"; export function useCreateDropdownItems({ hideFolder, diff --git a/apps/yaak-client/hooks/useDeleteSendHistory.tsx b/apps/yaak-client/hooks/useDeleteSendHistory.tsx index d64f886b3..c1d88457d 100644 --- a/apps/yaak-client/hooks/useDeleteSendHistory.tsx +++ b/apps/yaak-client/hooks/useDeleteSendHistory.tsx @@ -7,10 +7,10 @@ import { useAtomValue } from "jotai"; import { showAlert } from "../lib/alert"; import { showConfirmDelete } from "../lib/confirm"; import { jotaiStore } from "../lib/jotai"; -import { pluralizeCount } from "../lib/pluralize"; import { rpc } from "../lib/rpc"; import { activeWorkspaceIdAtom } from "./useActiveWorkspace"; import { useFastMutation } from "./useFastMutation"; +import { pluralizeCount } from "@yaakapp-internal/lib/pluralize"; export function useDeleteSendHistory() { const httpResponses = useAtomValue(httpResponsesAtom); diff --git a/apps/yaak-client/hooks/useExportData.tsx b/apps/yaak-client/hooks/useExportData.tsx index 8cf8ebba4..dba222447 100644 --- a/apps/yaak-client/hooks/useExportData.tsx +++ b/apps/yaak-client/hooks/useExportData.tsx @@ -23,7 +23,6 @@ export function useExportData() { id: "export-data", title: "Export Data", size: "md", - noPadding: true, render: ({ hide }) => ( ( tabValue: T, diff --git a/apps/yaak-client/hooks/useHotKey.ts b/apps/yaak-client/hooks/useHotKey.ts index 237786b5d..151624ba7 100644 --- a/apps/yaak-client/hooks/useHotKey.ts +++ b/apps/yaak-client/hooks/useHotKey.ts @@ -1,11 +1,26 @@ import { platform } from "@yaakapp-internal/platform"; import { debounce } from "@yaakapp-internal/lib"; import { settingsAtom } from "@yaakapp-internal/models"; +import { + formatHotkey, + type HotkeyContextValue, + type HotKeyOptions, + useFormattedHotkey, + useHotKey, +} from "@yaakapp-internal/ui"; import { atom, useAtomValue } from "jotai"; -import { useEffect } from "react"; -import { capitalize } from "../lib/capitalize"; +import { useEffect, useMemo } from "react"; import { jotaiStore } from "../lib/jotai"; +export type { HotKeyOptions }; +export { useFormattedHotkey, useHotKey }; + +declare module "@yaakapp-internal/ui" { + interface HotkeyActionRegistry { + action: HotkeyAction; + } +} + const HOLD_KEYS = ["Shift", "Control", "Command", "Alt", "Meta"]; const SINGLE_WHITELIST = ["Delete", "Enter", "Backspace"]; @@ -188,12 +203,6 @@ export const hotkeyActions: HotkeyAction[] = ( return hotkeyLabels[a].localeCompare(hotkeyLabels[b]); }); -export type HotKeyOptions = { - enable?: boolean | (() => boolean); - priority?: number; - allowDefault?: boolean; -}; - interface Callback { action: HotkeyAction; callback: (e: KeyboardEvent) => void; @@ -210,25 +219,35 @@ const clearCurrentKeysDebounced = debounce(() => { jotaiStore.set(currentKeysAtom, new Set([])); }, 5000); -export function useHotKey( - action: HotkeyAction | null, +function registerHotkey( + action: HotkeyAction, callback: (e: KeyboardEvent) => void, - options: HotKeyOptions = {}, + options: HotKeyOptions, ) { - useEffect(() => { - if (action == null) return; - jotaiStore.set(callbacksAtom, (prev) => { - const without = prev.filter((cb) => { - const isTheSame = cb.action === action && cb.options.priority === options.priority; - return !isTheSame; - }); - const newCb: Callback = { action, callback, options }; - return [...without, newCb]; + jotaiStore.set(callbacksAtom, (prev) => { + const without = prev.filter((cb) => { + const isTheSame = cb.action === action && cb.options.priority === options.priority; + return !isTheSame; }); - return () => { - jotaiStore.set(callbacksAtom, (prev) => prev.filter((cb) => cb.callback !== callback)); - }; - }, [action, callback, options]); + const newCb: Callback = { action, callback, options }; + return [...without, newCb]; + }); + return () => { + jotaiStore.set(callbacksAtom, (prev) => prev.filter((cb) => cb.callback !== callback)); + }; +} + +export function useHotkeyContextValue(): HotkeyContextValue { + const hotkeys = useAtomValue(hotkeysAtom); + return useMemo( + () => ({ + os: platform.osType(), + bindingFor: (action: HotkeyAction) => hotkeys[action] ?? null, + labelFor: (action: HotkeyAction) => hotkeyLabels[action], + register: registerHotkey, + }), + [hotkeys], + ); } export function useSubscribeHotKeys() { @@ -330,66 +349,7 @@ export function getHotkeyScope(action: HotkeyAction): string { } export function formatHotkeyString(trigger: string): string[] { - const os = platform.osType(); - const parts = trigger.split("+"); - const labelParts: string[] = []; - - for (const p of parts) { - if (os === "macos") { - if (p === "Meta") { - labelParts.push("⌘"); - } else if (p === "Shift") { - labelParts.push("⇧"); - } else if (p === "Control") { - labelParts.push("⌃"); - } else if (p === "Alt") { - labelParts.push("⌥"); - } else if (p === "Enter") { - // 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") { - labelParts.push("⌫"); - } else if (p === "Delete") { - labelParts.push("⌦"); - } else if (p === "Minus") { - labelParts.push("-"); - } else if (p === "Plus") { - labelParts.push("+"); - } else if (p === "Equal") { - labelParts.push("="); - } else if (p === "Space") { - labelParts.push("Space"); - } else { - labelParts.push(capitalize(p)); - } - } else { - if (p === "Control") { - labelParts.push("Ctrl"); - } else if (p === "Space") { - labelParts.push("Space"); - } else { - labelParts.push(capitalize(p)); - } - } - } - - if (os === "macos") { - return labelParts; - } - return [labelParts.join("+")]; -} - -export function useFormattedHotkey(action: HotkeyAction | null): string[] | null { - const hotkeys = useAtomValue(hotkeysAtom); - const trigger = action != null ? (hotkeys[action]?.[0] ?? null) : null; - if (trigger == null) { - return null; - } - - return formatHotkeyString(trigger); + return formatHotkey(trigger, platform.osType()); } function compareKeys(keysA: string[], keysB: string[]) { diff --git a/apps/yaak-client/hooks/useHttpBodyLoaders.ts b/apps/yaak-client/hooks/useHttpBodyLoaders.ts new file mode 100644 index 000000000..bb5d6a5d2 --- /dev/null +++ b/apps/yaak-client/hooks/useHttpBodyLoaders.ts @@ -0,0 +1,64 @@ +import { useQueryClient } from "@tanstack/react-query"; +import type { HttpResponse } from "@yaakapp-internal/models"; +import type { HttpBodyLoader, HttpBodyResult } from "@yaakapp-internal/ui"; +import { useMemo } from "react"; +import { getResponseBodyText } from "../lib/responseBody"; +import { getRequestBodyText, useHttpRequestBody } from "./useHttpRequestBody"; +import { + responseBodyTextQuery, + useResponseBodyBytes, + useResponseBodyText, +} from "./useResponseBodyText"; +import { useResponseBodyUrl } from "./useResponseBodyUrl"; + +interface QueryLike { + data: T | undefined; + isLoading: boolean; + error: Error | null; +} + +function result({ data, isLoading, error }: QueryLike): HttpBodyResult { + return { data, isLoading, error }; +} + +const noUrl: HttpBodyResult = { data: null, isLoading: false, error: null }; + +/** Reads a response's stored body the ways the shared viewers ask for it */ +export function useResponseBodyLoader(response: HttpResponse | null): HttpBodyLoader | null { + const queryClient = useQueryClient(); + return useMemo(() => { + if (response == null) return null; + return { + useText: () => result(useResponseBodyText({ response, filter: null })), + useBytes: () => result(useResponseBodyBytes({ response })), + useUrl: (enabled: boolean) => { + // Waiting on a URL that isn't asked for yet still counts as loading + const url = useResponseBodyUrl(enabled ? response : null); + return { data: url.data, isLoading: url.isPending, error: url.error }; + }, + // Shares the response body query's cache, so applying and showing a filter cost one RPC + filterText: (filter: string) => + queryClient.fetchQuery(responseBodyTextQuery({ response, filter })), + getText: () => getResponseBodyText({ response, filter: null }), + }; + }, [queryClient, response]); +} + +/** Reads the body a response's request was sent with */ +export function useRequestBodyLoader(response: HttpResponse | null): HttpBodyLoader | null { + return useMemo(() => { + if (response == null) return null; + return { + useText: () => { + const body = useHttpRequestBody(response); + return { ...result(body), data: body.data?.bodyText ?? null }; + }, + useBytes: () => { + const body = useHttpRequestBody(response); + return { ...result(body), data: body.data?.body ?? null }; + }, + useUrl: () => noUrl, + getText: () => getRequestBodyText(response).then((d) => d?.bodyText ?? ""), + }; + }, [response]); +} diff --git a/apps/yaak-client/hooks/useHttpRequestBody.ts b/apps/yaak-client/hooks/useHttpRequestBody.ts index eb86ef068..f625e2e43 100644 --- a/apps/yaak-client/hooks/useHttpRequestBody.ts +++ b/apps/yaak-client/hooks/useHttpRequestBody.ts @@ -1,5 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import type { HttpResponse } from "@yaakapp-internal/models"; +import { decodeBodyText } from "@yaakapp-internal/lib/decodeBodyText"; +import { getContentTypeFromHeaders } from "../lib/model_util"; import { rpc } from "../lib/rpc"; export function useHttpRequestBody(response: HttpResponse | null) { @@ -27,6 +29,6 @@ export async function getRequestBodyText(response: HttpResponse | null) { } const body = new Uint8Array(data); - const bodyText = new TextDecoder("utf-8", { fatal: false }).decode(body); + const bodyText = decodeBodyText(body, getContentTypeFromHeaders(response.requestHeaders)); return { body, bodyText }; } diff --git a/apps/yaak-client/hooks/usePluginInfo.ts b/apps/yaak-client/hooks/usePluginInfo.ts index 4a7f345e6..68effafd8 100644 --- a/apps/yaak-client/hooks/usePluginInfo.ts +++ b/apps/yaak-client/hooks/usePluginInfo.ts @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQueries, useQuery } from "@tanstack/react-query"; import type { Plugin } from "@yaakapp-internal/models"; import { pluginsAtom } from "@yaakapp-internal/models"; import type { PluginMetadata } from "@yaakapp-internal/plugins"; @@ -24,6 +24,22 @@ export function usePluginInfo(id: string | null) { }); } +/** Every plugin with its metadata, once loaded; the ones still loading are left out. */ +export function usePluginInfos() { + const plugins = useAtomValue(pluginsAtom); + return useQueries({ + queries: plugins.map((plugin) => ({ + queryKey: pluginInfoKey(plugin.id, plugin), + queryFn: () => rpc("cmd_plugin_info", { id: plugin.id }), + })), + combine: (results) => + results.flatMap((result, i) => { + const plugin = plugins[i]; + return result.data == null || plugin == null ? [] : [{ plugin, info: result.data }]; + }), + }); +} + export function invalidateAllPluginInfo() { queryClient.invalidateQueries({ queryKey: ["plugin_info"] }).catch(console.error); } diff --git a/apps/yaak-client/hooks/useRandomKey.ts b/apps/yaak-client/hooks/useRandomKey.ts index a615088cb..c3f42f26e 100644 --- a/apps/yaak-client/hooks/useRandomKey.ts +++ b/apps/yaak-client/hooks/useRandomKey.ts @@ -1,8 +1 @@ -import { useCallback, useState } from "react"; -import { generateId } from "../lib/generateId"; - -export function useRandomKey(initialValue?: string) { - const [value, setValue] = useState(initialValue ?? generateId()); - const regenerate = useCallback(() => setValue(generateId()), []); - return [value, regenerate] as const; -} +export { useRandomKey } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/hooks/useRequestUpdateKey.ts b/apps/yaak-client/hooks/useRequestUpdateKey.ts index dd570125b..aef282561 100644 --- a/apps/yaak-client/hooks/useRequestUpdateKey.ts +++ b/apps/yaak-client/hooks/useRequestUpdateKey.ts @@ -1,8 +1,8 @@ import { platform } from "@yaakapp-internal/platform"; import type { ModelPayload } from "@yaakapp-internal/models"; import { atom, useAtomValue } from "jotai"; -import { generateId } from "../lib/generateId"; import { jotaiStore } from "../lib/jotai"; +import { generateId } from "@yaakapp-internal/lib/generateId"; const requestUpdateKeyAtom = atom>({}); diff --git a/apps/yaak-client/hooks/useResponseFilter.ts b/apps/yaak-client/hooks/useResponseFilter.ts deleted file mode 100644 index bc6cd004c..000000000 --- a/apps/yaak-client/hooks/useResponseFilter.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { useCallback, useState } from "react"; -import { createGlobalState } from "react-use"; -import type { RecentFilter } from "./useRecentFilters"; -import { useRecentFilters } from "./useRecentFilters"; - -/** What's typed in the filter box. `null` means the filter box is closed */ -const useFilterTextMap = createGlobalState>({}); - -/** What's actually applied to the response. Only changes on an explicit apply */ -const useAppliedFilterMap = createGlobalState>({}); - -export interface ResponseFilterApi { - stateKey: string | null; - /** Draft text in the filter box, or `null` when the box is closed */ - filterText: string | null; - /** The expression currently filtering the response */ - appliedFilter: string | null; - isSearching: boolean; - /** The box holds an expression that isn't the one currently applied */ - isDirty: boolean; - /** Bumped when the (uncontrolled) filter input must re-read its defaultValue */ - filterUpdateKey: number; - setFilterText: (value: string | null) => void; - /** Apply the expression to the response, recording it if the filter accepts it */ - applyFilter: (value: string) => void; - /** Like applyFilter, but also replaces what's shown in the filter box */ - replaceFilter: (value: string) => void; - toggleSearch: () => void; - recentFilters: RecentFilter[]; - removeRecentFilter: (value: string) => void; - togglePinRecentFilter: (value: string) => void; - clearRecentFilters: () => void; -} - -/** - * Draft/applied state and history for a response filter (JSONPath/XPath). - * - * History records at most one entry per apply gesture, and only after `runFilter` - * confirms the plugin accepts the expression — the plugin is the sole judge of - * validity. Because nothing but the gesture ever writes, refetches can't resurrect - * deleted entries and a gesture can't record into another request's history. - */ -export function useResponseFilter({ - stateKey, - runFilter, -}: { - stateKey: string | null; - /** Evaluate an expression, rejecting if the filter plugin reports an error */ - runFilter: (filter: string) => Promise; -}): ResponseFilterApi { - const [filterTextMap, setFilterTextMap] = useFilterTextMap(); - const [appliedFilterMap, setAppliedFilterMap] = useAppliedFilterMap(); - const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null; - const appliedFilter = stateKey ? (appliedFilterMap[stateKey] ?? null) : null; - - const setFilterText = useCallback( - (v: string | null) => { - if (!stateKey) return; - setFilterTextMap((m) => ({ ...m, [stateKey]: v })); - }, - [stateKey, setFilterTextMap], - ); - - const setAppliedFilter = useCallback( - (v: string | null) => { - if (!stateKey) return; - setAppliedFilterMap((m) => ({ ...m, [stateKey]: v })); - }, - [stateKey, setAppliedFilterMap], - ); - - const { - recentFilters, - addFilter, - removeFilter: removeRecentFilter, - togglePin: togglePinRecentFilter, - clearFilters: clearRecentFilters, - } = useRecentFilters(stateKey); - - const applyFilter = useCallback( - (value: string) => { - setFilterText(value); - const applied = value.trim() === "" ? null : value.trim(); - setAppliedFilter(applied); - if (applied == null) return; - runFilter(applied).then( - () => addFilter(applied), - () => {}, // Rejected by the filter plugin — don't record - ); - }, - [setFilterText, setAppliedFilter, runFilter, addFilter], - ); - - const [filterUpdateKey, setFilterUpdateKey] = useState(0); - const replaceFilter = useCallback( - (value: string) => { - applyFilter(value); - setFilterUpdateKey((k) => k + 1); - }, - [applyFilter], - ); - - const isSearching = filterText != null; - const toggleSearch = useCallback(() => { - if (isSearching) { - setFilterText(null); - setAppliedFilter(null); - } else { - setFilterText(""); - } - }, [isSearching, setFilterText, setAppliedFilter]); - - return { - stateKey, - filterText, - appliedFilter, - isSearching, - isDirty: filterText != null && filterText.trim() !== (appliedFilter ?? ""), - filterUpdateKey, - setFilterText, - applyFilter, - replaceFilter, - toggleSearch, - recentFilters, - removeRecentFilter, - togglePinRecentFilter, - clearRecentFilters, - }; -} diff --git a/apps/yaak-client/hooks/useSendAnyHttpRequest.test.ts b/apps/yaak-client/hooks/useSendAnyHttpRequest.test.ts new file mode 100644 index 000000000..fac2d234f --- /dev/null +++ b/apps/yaak-client/hooks/useSendAnyHttpRequest.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + platform: { httpProxyUrl: "https://web.yaak.test" as string | null }, + showConfirm: vi.fn<() => Promise>(), + flushAllModelWrites: vi.fn(), + rpc: vi.fn(), +})); + +vi.mock("@yaakapp-internal/platform", () => ({ platform: mocks.platform })); +vi.mock("@yaakapp-internal/models", () => ({ flushAllModelWrites: mocks.flushAllModelWrites })); +vi.mock("../lib/confirm", () => ({ showConfirm: mocks.showConfirm })); +vi.mock("../lib/appInfo", () => ({ appInfo: { identifier: "app.yaak.web" } })); +vi.mock("../lib/rpc", () => ({ rpc: mocks.rpc })); +vi.mock("../lib/toast", () => ({ showToast: vi.fn() })); +vi.mock("./useActiveCookieJar", () => ({ getActiveCookieJar: () => ({ id: "cj_test" }) })); +vi.mock("./useActiveEnvironment", () => ({ getActiveEnvironment: () => ({ id: "ev_test" }) })); + +async function sender() { + return (await import("./useSendAnyHttpRequest")).sendAnyHttpRequest; +} + +describe("web proxy confirmation before sending", () => { + let stored: Map; + + beforeEach(() => { + vi.resetModules(); + vi.resetAllMocks(); + mocks.platform.httpProxyUrl = "https://web.yaak.test"; + mocks.rpc.mockResolvedValue({ id: "rs_test" }); + stored = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => stored.get(key) ?? null, + setItem: (key: string, value: string) => stored.set(key, value), + }); + }); + + afterEach(() => vi.unstubAllGlobals()); + + test("blocks all pending sends until one shared confirmation is accepted", async () => { + let decide!: (accepted: boolean) => void; + mocks.showConfirm.mockReturnValue( + new Promise((resolve) => { + decide = resolve; + }), + ); + const send = await sender(); + const first = send.mutateAsync("rq_one"); + const second = send.mutateAsync("rq_two"); + + expect(mocks.showConfirm).toHaveBeenCalledTimes(1); + expect(mocks.flushAllModelWrites).not.toHaveBeenCalled(); + expect(mocks.rpc).not.toHaveBeenCalled(); + decide(true); + await Promise.all([first, second]); + + expect(mocks.rpc).toHaveBeenCalledTimes(2); + expect(mocks.rpc).toHaveBeenCalledWith("cmd_send_http_request", { + requestId: "rq_one", + environmentId: "ev_test", + cookieJarId: "cj_test", + }); + expect(stored.size).toBe(1); + }); + + test("cancel sends nothing, remembers nothing, and asks again next time", async () => { + mocks.showConfirm.mockResolvedValue(false); + const send = await sender(); + expect(await send.mutateAsync("rq_one")).toBeNull(); + expect(await send.mutateAsync("rq_one")).toBeNull(); + expect(mocks.showConfirm).toHaveBeenCalledTimes(2); + expect(mocks.flushAllModelWrites).not.toHaveBeenCalled(); + expect(mocks.rpc).not.toHaveBeenCalled(); + expect(stored.size).toBe(0); + }); + + test("cancel applies to every send waiting on the dialog", async () => { + mocks.showConfirm.mockResolvedValue(false); + const send = await sender(); + expect(await Promise.all([send.mutateAsync("rq_one"), send.mutateAsync("rq_two")])).toEqual([ + null, + null, + ]); + expect(mocks.showConfirm).toHaveBeenCalledTimes(1); + expect(mocks.rpc).not.toHaveBeenCalled(); + }); + + test("remembers acceptance across module reloads for the same proxy", async () => { + mocks.showConfirm.mockResolvedValue(true); + await (await sender()).mutateAsync("rq_one"); + vi.resetModules(); + await (await sender()).mutateAsync("rq_two"); + expect(mocks.showConfirm).toHaveBeenCalledTimes(1); + expect(mocks.rpc).toHaveBeenCalledTimes(2); + }); + + test("requires a new decision when the proxy changes", async () => { + mocks.showConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + const send = await sender(); + await send.mutateAsync("rq_one"); + mocks.platform.httpProxyUrl = "https://another-proxy.test"; + expect(await send.mutateAsync("rq_two")).toBeNull(); + expect(mocks.showConfirm).toHaveBeenCalledTimes(2); + expect(mocks.rpc).toHaveBeenCalledTimes(1); + }); + + test("still asks and remembers in this tab if browser storage is unavailable", async () => { + const unavailable = () => { + throw new Error("Storage unavailable"); + }; + vi.stubGlobal("localStorage", { getItem: unavailable, setItem: unavailable }); + mocks.showConfirm.mockResolvedValue(true); + const send = await sender(); + await send.mutateAsync("rq_one"); + await send.mutateAsync("rq_two"); + expect(mocks.showConfirm).toHaveBeenCalledTimes(1); + expect(mocks.rpc).toHaveBeenCalledTimes(2); + }); + + test("desktop sends directly without confirmation", async () => { + mocks.platform.httpProxyUrl = null; + await (await sender()).mutateAsync("rq_one"); + expect(mocks.showConfirm).not.toHaveBeenCalled(); + expect(mocks.rpc).toHaveBeenCalledTimes(1); + expect(stored.size).toBe(0); + }); + + test("an empty selection does not prompt or send", async () => { + expect(await (await sender()).mutateAsync(null)).toBeNull(); + expect(mocks.showConfirm).not.toHaveBeenCalled(); + expect(mocks.rpc).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/yaak-client/hooks/useSendAnyHttpRequest.ts b/apps/yaak-client/hooks/useSendAnyHttpRequest.ts index c2d4d4970..f980276d1 100644 --- a/apps/yaak-client/hooks/useSendAnyHttpRequest.ts +++ b/apps/yaak-client/hooks/useSendAnyHttpRequest.ts @@ -1,5 +1,6 @@ import type { HttpResponse } from "@yaakapp-internal/models"; import { flushAllModelWrites } from "@yaakapp-internal/models"; +import { confirmWebProxy } from "../lib/confirmWebProxy"; import { rpc } from "../lib/rpc"; import { getActiveCookieJar } from "./useActiveCookieJar"; import { getActiveEnvironment } from "./useActiveEnvironment"; @@ -10,6 +11,10 @@ async function sendAnyHttpRequestById(id: string | null): Promise(initialValue); - const toggle = useCallback(() => setValue((v) => !v), []); - return [value, toggle, setValue] as const; -} +// From its own entry rather than the package index, so tests that mock the index keep it +export { useToggle } from "@yaakapp-internal/ui/useToggle"; diff --git a/apps/yaak-client/hooks/useUninstallPlugin.tsx b/apps/yaak-client/hooks/useUninstallPlugin.tsx new file mode 100644 index 000000000..faa6f5163 --- /dev/null +++ b/apps/yaak-client/hooks/useUninstallPlugin.tsx @@ -0,0 +1,31 @@ +import { useMutation } from "@tanstack/react-query"; +import { uninstallPlugin } from "@yaakapp-internal/plugins"; +import { InlineCode } from "@yaakapp-internal/ui"; +import { showConfirmDelete } from "../lib/confirm"; +import { minPromiseMillis } from "../lib/minPromiseMillis"; + +/** Confirms, then uninstalls. Null until there is a plugin to uninstall. */ +export function usePromptUninstallPlugin(pluginId: string | null, name: string) { + const mut = useMutation({ + mutationKey: ["uninstall_plugin", pluginId], + mutationFn: async () => { + if (pluginId == null) return; + + const confirmed = await showConfirmDelete({ + id: `uninstall-plugin-${pluginId}`, + title: "Uninstall Plugin", + confirmText: "Uninstall", + description: ( + <> + Permanently uninstall {name}? + + ), + }); + if (confirmed) { + await minPromiseMillis(uninstallPlugin(pluginId), 700); + } + }, + }); + + return pluginId == null ? null : mut; +} diff --git a/apps/yaak-client/index.html b/apps/yaak-client/index.html index 9417af14d..b59379cc7 100644 --- a/apps/yaak-client/index.html +++ b/apps/yaak-client/index.html @@ -3,6 +3,9 @@ + + + Yaak App @@ -28,6 +31,7 @@
+ diff --git a/apps/yaak-client/lib/agentSetup.test.ts b/apps/yaak-client/lib/agentSetup.test.ts new file mode 100644 index 000000000..c7ea135f7 --- /dev/null +++ b/apps/yaak-client/lib/agentSetup.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "vite-plus/test"; +import { AGENT_CLIENTS, cliSetup, skillInstallCommand } from "./agentSetup"; + +describe("cliSetup", () => { + test("uses each client's own add command", () => { + expect(cliSetup("claude_code").snippet).toBe("claude mcp add -s user yaak -- yaak mcp"); + expect(cliSetup("codex").snippet).toBe("codex mcp add yaak -- yaak mcp"); + }); + + test("names a file only when the snippet goes in one", () => { + expect(cliSetup("claude_code").where).toBeNull(); + expect(cliSetup("codex").where).toBeNull(); + expect(cliSetup("cursor").where).toBe("Add to ~/.cursor/mcp.json."); + expect(cliSetup("vscode").where).toContain("mcp.json"); + expect(cliSetup("other").where).not.toBeNull(); + }); + + test("writes the config shape each client reads", () => { + expect(JSON.parse(cliSetup("cursor").snippet)).toEqual({ + mcpServers: { yaak: { command: "yaak", args: ["mcp"] } }, + }); + expect(JSON.parse(cliSetup("vscode").snippet)).toEqual({ + servers: { yaak: { type: "stdio", command: "yaak", args: ["mcp"] } }, + }); + expect(JSON.parse(cliSetup("other").snippet)).toEqual({ + mcpServers: { yaak: { command: "yaak", args: ["mcp"] } }, + }); + }); + + test("keeps argument lists on one line", () => { + expect(cliSetup("cursor").snippet).toContain(`"args": ["mcp"]`); + }); + + test("covers every listed client", () => { + for (const { value } of AGENT_CLIENTS) { + expect(cliSetup(value).snippet).not.toBe(""); + } + }); +}); + +describe("skillInstallCommand", () => { + test("targets the agents the installer knows by name", () => { + expect(skillInstallCommand("claude_code")).toBe("yaak agent install --agent claude-code"); + expect(skillInstallCommand("codex")).toBe("yaak agent install --agent codex"); + expect(skillInstallCommand("cursor")).toBe("yaak agent install --agent cursor"); + }); + + test("installs everywhere for agents it cannot target", () => { + expect(skillInstallCommand("vscode")).toBe("yaak agent install"); + expect(skillInstallCommand("other")).toBe("yaak agent install"); + }); +}); diff --git a/apps/yaak-client/lib/agentSetup.ts b/apps/yaak-client/lib/agentSetup.ts new file mode 100644 index 000000000..fe969f727 --- /dev/null +++ b/apps/yaak-client/lib/agentSetup.ts @@ -0,0 +1,69 @@ +import type { AgentClient } from "@yaakapp-internal/rpc-schema"; + +export type { AgentClient }; + +export const AGENT_CLIENTS: { label: string; value: AgentClient }[] = [ + { label: "Claude Code", value: "claude_code" }, + { label: "Codex", value: "codex" }, + { label: "Cursor", value: "cursor" }, + { label: "VS Code", value: "vscode" }, + { label: "Other", value: "other" }, +]; + +export interface AgentSetup { + /** The file the snippet goes in, as a sentence, when it is a file rather than a command */ + where: string | null; + snippet: string; + language: "shell" | "json" | "text"; +} + +/** `yaak agent install`, targeted at `client` when the installer knows it by name. */ +export function skillInstallCommand(client: AgentClient): string { + switch (client) { + case "claude_code": + return "yaak agent install --agent claude-code"; + case "codex": + return "yaak agent install --agent codex"; + case "cursor": + return "yaak agent install --agent cursor"; + case "vscode": + case "other": + return "yaak agent install"; + } +} + +/** Setup for `yaak mcp`, which the client launches over stdio. */ +export function cliSetup(client: AgentClient): AgentSetup { + switch (client) { + case "claude_code": + return { where: null, snippet: "claude mcp add -s user yaak -- yaak mcp", language: "shell" }; + case "codex": + return { where: null, snippet: "codex mcp add yaak -- yaak mcp", language: "shell" }; + case "cursor": + return { + where: "Add to ~/.cursor/mcp.json.", + snippet: json({ mcpServers: { yaak: { command: "yaak", args: ["mcp"] } } }), + language: "json", + }; + case "vscode": + return { + where: "Add to your user mcp.json (MCP: Open User Configuration).", + snippet: json({ servers: { yaak: { type: "stdio", command: "yaak", args: ["mcp"] } } }), + language: "json", + }; + case "other": + return { + where: "For clients that read an mcpServers config, like Claude Desktop and Windsurf.", + snippet: json({ mcpServers: { yaak: { command: "yaak", args: ["mcp"] } } }), + language: "json", + }; + } +} + +/** Pretty JSON with arrays of plain values kept on one line. */ +function json(value: unknown): string { + return JSON.stringify(value, null, 2).replace( + /\[\n\s*([^[\]{}]*?)\n\s*\]/g, + (_, items: string) => `[${items.split(/,\n\s*/).join(", ")}]`, + ); +} diff --git a/apps/yaak-client/lib/confirmWebProxy.test.tsx b/apps/yaak-client/lib/confirmWebProxy.test.tsx new file mode 100644 index 000000000..9ea0a1a18 --- /dev/null +++ b/apps/yaak-client/lib/confirmWebProxy.test.tsx @@ -0,0 +1,64 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test"; +import type { DialogInstance } from "../components/Dialogs"; + +const mocks = vi.hoisted(() => ({ + platform: { httpProxyUrl: "https://web.yaak.app" }, + showDialog: vi.fn<(dialog: DialogInstance) => void>(), +})); + +vi.mock("@yaakapp-internal/platform", () => ({ platform: mocks.platform })); +vi.mock("./appInfo", () => ({ appInfo: { identifier: "app.yaak.web" } })); +vi.mock("./dialog", () => ({ showDialog: mocks.showDialog })); +vi.mock("../hooks/useHotKey", () => ({ + useHotKey: vi.fn(), + useFormattedHotkey: () => null, +})); + +describe("proxy consent disclosure", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.stubGlobal("localStorage", { getItem: () => null }); + }); + + afterEach(() => vi.unstubAllGlobals()); + + test.each([ + ["Yaak-hosted", "https://web.yaak.app"], + ["self-hosted", "https://yaak.example.com"], + ["local", "http://localhost:8080"], + ["split deployment", "https://send.example.com/relay"], + ])("identifies the %s proxy and requires an explicit decision", async (_, proxyUrl) => { + mocks.platform.httpProxyUrl = proxyUrl; + const { confirmWebProxy } = await import("./confirmWebProxy"); + void confirmWebProxy(); + + const dialog = mocks.showDialog.mock.calls[0]?.[0]; + expect(dialog).toMatchObject({ + id: "web-proxy-consent", + title: "Requests in Yaak Web use a proxy", + size: "sm", + disableClose: true, + }); + if (dialog == null) throw new Error("Expected the proxy consent dialog"); + + const disclosure = renderToStaticMarkup(<>{dialog.description}); + expect(disclosure).toContain(`>${proxyUrl}`); + expect(disclosure).toMatch(/request and response data/i); + expect(disclosure).toContain("credentials"); + expect(disclosure).toContain("server shown below"); + expect(disclosure).not.toContain("Yaak’s servers"); + expect(disclosure).toContain( + 'href="https://yaak.app/docs/getting-started/web-proxy?ref=app.yaak.web"', + ); + expect(disclosure).toContain('target="_blank"'); + expect(disclosure).toContain("how the proxy works"); + + const Actions = dialog.render; + const actions = renderToStaticMarkup(); + expect(actions).toMatch(/]*type="submit"/); + expect(actions).toContain(">Send via Proxy<"); + expect(actions).toContain(">Cancel<"); + }); +}); diff --git a/apps/yaak-client/lib/confirmWebProxy.tsx b/apps/yaak-client/lib/confirmWebProxy.tsx new file mode 100644 index 000000000..796e8fac4 --- /dev/null +++ b/apps/yaak-client/lib/confirmWebProxy.tsx @@ -0,0 +1,60 @@ +import { platform } from "@yaakapp-internal/platform"; +import { InlineCode } from "@yaakapp-internal/ui"; +import { Link } from "../components/core/Link"; +import { showConfirm } from "./confirm"; + +const acceptedProxies = new Set(); +const pendingConfirmations = new Map>(); + +/** One decision for all pending sends; only acceptance is remembered. */ +export function confirmWebProxy(): Promise { + const proxyUrl = platform.httpProxyUrl; + if (proxyUrl == null) return Promise.resolve(true); + + const key = `yaak.webProxyConsent.v1:${proxyUrl}`; + try { + if (localStorage.getItem(key) === "accepted") return Promise.resolve(true); + } catch { + // Storage can be unavailable in private or restricted browsing. + } + if (acceptedProxies.has(proxyUrl)) return Promise.resolve(true); + + const pending = pendingConfirmations.get(proxyUrl); + if (pending != null) return pending; + + const confirmation = showConfirm({ + id: "web-proxy-consent", + title: "Requests in Yaak Web use a proxy", + confirmText: "Send via Proxy", + description: ( +
+

+ Yaak Web sends requests through a hosted proxy. Request and response data, including + credentials, pass through the server shown below. +

+

+ {proxyUrl} +

+

+ Learn more about{" "} + how the proxy works. +

+
+ ), + }) + .then((accepted) => { + if (accepted) { + acceptedProxies.add(proxyUrl); + try { + localStorage.setItem(key, "accepted"); + } catch { + // Keep acceptance for this tab if it cannot be persisted. + } + } + return accepted; + }) + .finally(() => pendingConfirmations.delete(proxyUrl)); + + pendingConfirmations.set(proxyUrl, confirmation); + return confirmation; +} diff --git a/apps/yaak-client/lib/contentType.ts b/apps/yaak-client/lib/contentType.ts index 326f8a8cd..c8d42ca3a 100644 --- a/apps/yaak-client/lib/contentType.ts +++ b/apps/yaak-client/lib/contentType.ts @@ -1,99 +1 @@ -import MimeType from "whatwg-mimetype"; -import type { EditorProps } from "../components/core/Editor/Editor"; - -export function languageFromContentType( - contentType: string | null, - content: string | null = null, -): EditorProps["language"] { - const justContentType = contentType?.split(";")[0] ?? contentType ?? ""; - if (justContentType.includes("json")) { - return "json"; - } - if (justContentType.includes("xml")) { - return "xml"; - } - if (justContentType.includes("html")) { - const detected = languageFromContent(content); - if (detected === "xml") { - // If it's detected as XML, but is already HTML, don't change it - return "html"; - } - return detected; - } - if (justContentType.includes("javascript")) { - // Sometimes `application/javascript` returns JSON, so try detecting that - return languageFromContent(content, "javascript"); - } - if (justContentType.includes("markdown")) { - return "markdown"; - } - - return languageFromContent(content, "text"); -} - -export function languageFromContent( - content: string | null, - fallback?: EditorProps["language"], -): EditorProps["language"] { - if (content == null) return "text"; - - const firstBytes = content.slice(0, 20).trim(); - - if (firstBytes.startsWith("{") || firstBytes.startsWith("[")) { - return "json"; - } - if ( - firstBytes.toLowerCase().startsWith(" normalized === textType || normalized.endsWith(textType)); -} - -export function getMimeTypeFromContentType(contentType: string): MimeType { - try { - return new MimeType(contentType); - } catch { - return new MimeType("text/plain"); - } -} +export { getMimeTypeFromContentType, isJSON, isProbablyTextContentType } from "@yaakapp-internal/ui/mimeType"; diff --git a/apps/yaak-client/lib/deleteModelWithConfirm.tsx b/apps/yaak-client/lib/deleteModelWithConfirm.tsx index ac4d50417..e92263f05 100644 --- a/apps/yaak-client/lib/deleteModelWithConfirm.tsx +++ b/apps/yaak-client/lib/deleteModelWithConfirm.tsx @@ -3,7 +3,7 @@ import { deleteModel, modelTypeLabel } from "@yaakapp-internal/models"; import { InlineCode } from "@yaakapp-internal/ui"; import { Prose } from "../components/Prose"; import { showConfirmDelete } from "./confirm"; -import { pluralizeCount } from "./pluralize"; +import { pluralizeCount } from "@yaakapp-internal/lib/pluralize"; import { resolvedModelName } from "./resolvedModelName"; export async function deleteModelWithConfirm( diff --git a/apps/yaak-client/lib/errorMessage.ts b/apps/yaak-client/lib/errorMessage.ts new file mode 100644 index 000000000..7db4add4c --- /dev/null +++ b/apps/yaak-client/lib/errorMessage.ts @@ -0,0 +1,3 @@ +export function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/apps/yaak-client/lib/getNodeText.ts b/apps/yaak-client/lib/getNodeText.ts index 96a1274ec..a0d71dc50 100644 --- a/apps/yaak-client/lib/getNodeText.ts +++ b/apps/yaak-client/lib/getNodeText.ts @@ -1,22 +1 @@ -import type { ReactNode } from "react"; - -/** - * Get the text content from a ReactNode - * https://stackoverflow.com/questions/50428910/get-text-content-from-node-in-react - */ -export function getNodeText(node: ReactNode): string { - if (typeof node === "string" || typeof node === "number") { - return String(node); - } - - if (Array.isArray(node)) { - return node.map(getNodeText).join(""); - } - - if (typeof node === "object" && node) { - // oxlint-disable-next-line no-explicit-any - return getNodeText((node as any).props.children); - } - - return ""; -} +export { getNodeText } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/lib/graphqlSchema.ts b/apps/yaak-client/lib/graphqlSchema.ts index 8e7bf30a9..b9c99ffc2 100644 --- a/apps/yaak-client/lib/graphqlSchema.ts +++ b/apps/yaak-client/lib/graphqlSchema.ts @@ -1,4 +1,5 @@ import type { GraphQLSchema, IntrospectionQuery } from "graphql"; +import { errorMessage } from "./errorMessage"; import { buildClientSchema, buildSchema, introspectionFromSchema } from "graphql"; // Accepts either a GraphQL introspection JSON ({ data: { __schema } } or @@ -45,7 +46,3 @@ export function tryBuildIntrospectionFromFile( }; } } - -function errorMessage(e: unknown): string { - return e instanceof Error ? e.message : String(e); -} diff --git a/apps/yaak-client/lib/httpViews.test.ts b/apps/yaak-client/lib/httpViews.test.ts new file mode 100644 index 000000000..a9fdb1c9d --- /dev/null +++ b/apps/yaak-client/lib/httpViews.test.ts @@ -0,0 +1,83 @@ +import type { HttpRequest, HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models"; +import { describe, expect, test } from "vite-plus/test"; +import { cookieHeadersFromEvents, httpRequestToView, httpResponseToView } from "./httpViews"; + +function event(e: HttpResponseEvent["event"]): HttpResponseEvent { + return { event: e } as HttpResponseEvent; +} + +describe("httpRequestToView", () => { + test("maps the request and reads its content type from the headers", () => { + const request = { + id: "rq_1", + method: "POST", + url: "https://yaak.app", + headers: [{ name: "content-type", value: "application/json", enabled: true }], + urlParameters: [{ name: "a", value: "1", enabled: true }], + bodyType: "application/json", + body: { text: "{}" }, + } as unknown as HttpRequest; + + expect(httpRequestToView(request)).toEqual({ + id: "rq_1", + method: "POST", + url: "https://yaak.app", + headers: request.headers, + urlParameters: request.urlParameters, + bodyType: "application/json", + body: { text: "{}" }, + contentType: "application/json", + auth: null, + }); + }); +}); + +describe("httpResponseToView", () => { + test("keys state to the request and puts the stored time in UTC", () => { + const response = { + id: "rs_1", + requestId: "rq_1", + state: "closed", + status: 200, + statusReason: "OK", + url: "https://yaak.app", + headers: [{ name: "Content-Type", value: "text/plain" }], + requestHeaders: [], + contentLength: 10, + contentLengthCompressed: 4, + requestContentLength: 0, + createdAt: "2026-11-04T22:10:00.123", + elapsed: 30, + elapsedHeaders: 20, + elapsedDns: 5, + error: null, + remoteAddr: null, + version: "HTTP/1.1", + } as unknown as HttpResponse; + + expect(httpResponseToView(response, undefined)).toMatchObject({ + id: "rs_1", + ownerId: "rq_1", + contentType: "text/plain", + startedAt: "2026-11-04T22:10:00.123Z", + elapsedMs: 30, + elapsedHeadersMs: 20, + elapsedDnsMs: 5, + contentLengthCompressed: 4, + cookieHeaders: { sent: [], received: [] }, + }); + }); +}); + +describe("cookieHeadersFromEvents", () => { + test("collects Cookie headers sent and Set-Cookie headers received", () => { + const events = [ + event({ type: "header_up", name: "Cookie", value: "a=1" }), + event({ type: "header_up", name: "Accept", value: "*/*" }), + event({ type: "header_down", name: "set-cookie", value: "b=2" }), + event({ type: "header_down", name: "Content-Type", value: "text/plain" }), + ]; + expect(cookieHeadersFromEvents(events)).toEqual({ sent: ["a=1"], received: ["b=2"] }); + expect(cookieHeadersFromEvents(undefined)).toEqual({ sent: [], received: [] }); + }); +}); diff --git a/apps/yaak-client/lib/httpViews.ts b/apps/yaak-client/lib/httpViews.ts new file mode 100644 index 000000000..fe7748f47 --- /dev/null +++ b/apps/yaak-client/lib/httpViews.ts @@ -0,0 +1,64 @@ +import type { HttpRequest, HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models"; +import type { HttpRequestView, HttpResponseView } from "@yaakapp-internal/ui"; +import { getContentTypeFromHeaders } from "./model_util"; + +/** A request as the shared request pane shows it. Auth stays with the client's own editor */ +export function httpRequestToView(request: HttpRequest): HttpRequestView { + return { + id: request.id, + method: request.method, + url: request.url, + headers: request.headers, + urlParameters: request.urlParameters, + bodyType: request.bodyType, + body: request.body, + contentType: getContentTypeFromHeaders(request.headers), + auth: null, + }; +} + +/** A response as the shared response pane shows it, with its cookies from the send's events */ +export function httpResponseToView( + response: HttpResponse, + events: HttpResponseEvent[] | undefined, +): HttpResponseView { + return { + id: response.id, + ownerId: response.requestId, + state: response.state, + status: response.status, + statusReason: response.statusReason, + url: response.url, + headers: response.headers, + requestHeaders: response.requestHeaders, + contentType: getContentTypeFromHeaders(response.headers), + contentLength: response.contentLength, + contentLengthCompressed: response.contentLengthCompressed, + requestContentLength: response.requestContentLength, + // Stored as UTC without a zone + startedAt: `${response.createdAt}Z`, + elapsedMs: response.elapsed, + elapsedHeadersMs: response.elapsedHeaders, + elapsedDnsMs: response.elapsedDns, + error: response.error, + remoteAddr: response.remoteAddr, + version: response.version, + cookieHeaders: cookieHeadersFromEvents(events), + }; +} + +/** The `Cookie` headers sent and `Set-Cookie` headers received, from a send's events */ +export function cookieHeadersFromEvents( + events: HttpResponseEvent[] | undefined, +): HttpResponseView["cookieHeaders"] { + const sent: string[] = []; + const received: string[] = []; + for (const { event } of events ?? []) { + if (event.type === "header_up" && event.name.toLowerCase() === "cookie") { + sent.push(event.value); + } else if (event.type === "header_down" && event.name.toLowerCase() === "set-cookie") { + received.push(event.value); + } + } + return { sent, received }; +} diff --git a/apps/yaak-client/lib/importData.tsx b/apps/yaak-client/lib/importData.tsx index b4296ee1a..a3984e2ec 100644 --- a/apps/yaak-client/lib/importData.tsx +++ b/apps/yaak-client/lib/importData.tsx @@ -8,22 +8,29 @@ import { import { FormattedError, VStack } from "@yaakapp-internal/ui"; import { Button } from "../components/core/Button"; import { ImportDataDialog } from "../components/ImportDataDialog"; -import { activeFolderAtom } from "../hooks/useActiveFolder"; +import type { ImportSourcePath } from "../components/ImportSourceList"; 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 { pluralizeCount } from "@yaakapp-internal/lib/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("cmd_import_data", { filePath, destination }); -const planUrl = (url: string, destination: ImportDestination) => - rpc("cmd_import_url", { url, destination }); +const planSources = (sources: ImportSourcePath[], destination: ImportDestination) => + rpc("cmd_import_data", { + filePaths: sources.filter((source) => source.kind !== "url").map((source) => source.path), + urls: sources.filter((source) => source.kind === "url").map((source) => source.path), + destination, + }); +const detectSource = (source: ImportSourcePath) => + rpc( + "cmd_detect_import_source", + source.kind === "url" ? { url: source.path } : { filePath: source.path }, + ); const listSources = (workspaceId: string) => rpc("cmd_list_import_sources", { workspaceId }); const findSourcesForOrigin = (args: { filePath?: string; url?: string }) => @@ -43,11 +50,11 @@ export const importData = createFastMutation({ return new Promise((resolve, reject) => { const currentWorkspace = jotaiStore.get(activeWorkspaceAtom); const workspaces = jotaiStore.get(workspacesAtom); - const selectedFolder = jotaiStore.get(activeFolderAtom); showDialog({ id: "import", title: "Import Data", size: "lg", + className: "h-[36rem]", disableClose: true, render: ({ hide }) => { const cancel = () => { @@ -68,9 +75,8 @@ export const importData = createFastMutation({ h.name.toLowerCase() === "content-type")?.value ?? null; } -export function getCharsetFromContentType(headers: HttpResponseHeader[]): string | null { - const contentType = getContentTypeFromHeaders(headers); - if (contentType == null) return null; - - const mimeType = getMimeTypeFromContentType(contentType); - return mimeType.parameters.get("charset") ?? null; -} - export function isBaseEnvironment(environment: Environment): boolean { return environment.parentModel === "workspace"; } diff --git a/apps/yaak-client/lib/onboarding.ts b/apps/yaak-client/lib/onboarding.ts new file mode 100644 index 000000000..a36f6ed9c --- /dev/null +++ b/apps/yaak-client/lib/onboarding.ts @@ -0,0 +1,44 @@ +import type { BatchUpsertResult } from "@yaakapp-internal/models"; +import { createGlobalModel } from "@yaakapp-internal/models"; +import { router } from "./router"; +import { rpc } from "./rpc"; +import { setKeyValue } from "./keyValueStore"; + +export type OnboardingChoice = + | "import" + | "import_yaak" + | "example" + | "fresh" + | "open_folder" + | "clone_git"; + +const NEW_WORKSPACE_NAME = "My Workspace"; + +/** + * Remembered so later surfaces can lean toward what the user came for (an importer, the + * example, or a blank slate). Nothing reads it yet. + */ +export function recordOnboardingChoice(choice: OnboardingChoice) { + setKeyValue({ + namespace: "global", + key: "onboarding", + value: { choice, at: new Date().toISOString() }, + }).catch(console.error); +} + +export async function createFreshWorkspace() { + const workspaceId = await createGlobalModel({ model: "workspace", name: NEW_WORKSPACE_NAME }); + await router.navigate({ to: "/workspaces/$workspaceId", params: { workspaceId } }); +} + +export async function createExampleWorkspace() { + const created = await rpc("cmd_create_example_workspace", {}); + const workspace = created.workspaces[0]; + if (workspace == null) throw new Error("Example workspace was not created"); + const firstRequest = created.httpRequests.find((r) => r.name === "List posts"); + await router.navigate({ + to: "/workspaces/$workspaceId", + params: { workspaceId: workspace.id }, + search: firstRequest ? { request_id: firstRequest.id } : {}, + }); +} diff --git a/apps/yaak-client/lib/prepareImportQuerystring.ts b/apps/yaak-client/lib/prepareImportQuerystring.ts index c6715ae56..9f20fcf89 100644 --- a/apps/yaak-client/lib/prepareImportQuerystring.ts +++ b/apps/yaak-client/lib/prepareImportQuerystring.ts @@ -1,5 +1,5 @@ import type { HttpUrlParameter } from "@yaakapp-internal/models"; -import { generateId } from "./generateId"; +import { generateId } from "@yaakapp-internal/lib/generateId"; export function prepareImportQuerystring( url: string, diff --git a/apps/yaak-client/lib/requestBodyConversion.test.ts b/apps/yaak-client/lib/requestBodyConversion.test.ts index aeebda3ca..1d5a4f008 100644 --- a/apps/yaak-client/lib/requestBodyConversion.test.ts +++ b/apps/yaak-client/lib/requestBodyConversion.test.ts @@ -92,6 +92,21 @@ describe("convertRequestBody", () => { }); }); + test.each(["__proto__", "constructor", "toString"])( + "preserves the form field %s when converting to JSON", + (name) => { + const body = convertRequestBody({ + fromBodyType: BODY_TYPE_FORM_URLENCODED, + toBodyType: BODY_TYPE_JSON, + body: { form: [{ enabled: true, name, value: "hello" }] }, + }); + + expect(body).toEqual({ + text: JSON.stringify({ [name]: "hello" }, null, 2), + }); + }, + ); + test("preserves text when converting to form bodies cannot build form pairs", () => { const body = convertRequestBody({ fromBodyType: BODY_TYPE_XML, diff --git a/apps/yaak-client/lib/requestBodyConversion.ts b/apps/yaak-client/lib/requestBodyConversion.ts index 26cff9938..6b33d49ec 100644 --- a/apps/yaak-client/lib/requestBodyConversion.ts +++ b/apps/yaak-client/lib/requestBodyConversion.ts @@ -161,7 +161,7 @@ function formBodyToUrlEncodedText(form: unknown[]): string { } function formBodyToObject(form: unknown[]) { - const result: Record = {}; + const result: Record = Object.create(null); for (const pair of form) { if (!isRecord(pair)) continue; diff --git a/apps/yaak-client/lib/resolvedModelName.cases.ts b/apps/yaak-client/lib/resolvedModelName.cases.ts new file mode 100644 index 000000000..230eee430 --- /dev/null +++ b/apps/yaak-client/lib/resolvedModelName.cases.ts @@ -0,0 +1,79 @@ +/* oxlint-disable no-template-curly-in-string */ + +/* + * Shared expectations for `resolvedModelName`. + * + * The request template function plugin carries a hand-copied `resolvedModelName` and template-tag + * scanner, because importing this module into the plugin bundle fails to build on Windows in CI. + * Both `resolvedModelName.test.ts` here and `plugins/template-function-request/tests/ + * resolvedModelName.test.ts` iterate these cases, so changing an expectation on this side + * automatically exercises the plugin's copy and a drifting copy fails its own suite. + * + * Keep this file dependency-free — the plugin test imports it by relative path, so anything it + * pulls in would have to resolve from both packages. + */ + +export interface ResolvedModelNameCase { + /** Test name, used as the `test.each` title on both sides. */ + name: string; + /** `url` of the `http_request` under test. */ + url: string; + /** `name` of the `http_request`, when the case is about an explicit name. */ + requestName?: string; + /** What `resolvedModelName` must return. */ + expected: string; +} + +export const resolvedModelNameCases: ResolvedModelNameCase[] = [ + { + name: "returns the name when there is one", + url: "https://example.com", + requestName: "My Request", + expected: "My Request", + }, + { + name: "replaces a simple tag with its contents", + url: "${[ base_url ]}/users", + expected: "base_url/users", + }, + { + name: "replaces a tag containing a quoted argument with a space", + url: "${[ fn(arg='my key') ]}/users", + expected: "fn(arg='my key')/users", + }, + { + name: "replaces each tag in a multi-tag string", + url: "${[ fn(arg='my key') ]}/a/${[ other(b='x y') ]}/b", + expected: "fn(arg='my key')/a/other(b='x y')/b", + }, + { + name: "keeps a quoted `]}` inside the tag", + url: "${[ fn(arg='x]}y') ]}/users", + expected: "fn(arg='x]}y')/users", + }, + { + name: "keeps an escaped quote inside the tag", + url: "${[ fn(arg='it\\'s ]}') ]}/users", + expected: "fn(arg='it\\'s ]}')/users", + }, + { + name: "closes at the first `]}` when a quote is left unterminated", + url: "${[ fn(arg='oops ]}/users", + expected: "fn(arg='oops/users", + }, + { + name: "stops each tag at its first closing bracket", + url: "${[ a ]}${[ b ]}", + expected: "ab", + }, + { + name: "strips the protocol", + url: "https://example.com/${[ path ]}", + expected: "example.com/path", + }, + { + name: "falls back to a generic name when the url is empty", + url: "", + expected: "HTTP Request", + }, +]; diff --git a/apps/yaak-client/lib/resolvedModelName.test.ts b/apps/yaak-client/lib/resolvedModelName.test.ts new file mode 100644 index 000000000..b945c1b21 --- /dev/null +++ b/apps/yaak-client/lib/resolvedModelName.test.ts @@ -0,0 +1,21 @@ +import type { HttpRequest } from "@yaakapp-internal/models"; +import { describe, expect, test, vi } from "vite-plus/test"; +import { resolvedModelNameCases } from "./resolvedModelName.cases"; + +// resolvedModelName only reads plain model fields, but its module imports pull in the +// platform singleton, which needs a browser. Stub them out to keep this a pure unit test. +vi.mock("@yaakapp-internal/models", () => ({ foldersAtom: {} })); +vi.mock("./jotai", () => ({ jotaiStore: { get: () => [] } })); + +const { resolvedModelName } = await import("./resolvedModelName"); + +function httpRequest(url: string, name = ""): HttpRequest { + return { id: "rq_test", model: "http_request", name, url } as HttpRequest; +} + +describe("resolvedModelName", () => { + // Shared with the plugin's hand-copied scanner. See `resolvedModelName.cases.ts`. + test.each(resolvedModelNameCases)("$name", ({ url, requestName, expected }) => { + expect(resolvedModelName(httpRequest(url, requestName))).toEqual(expected); + }); +}); diff --git a/apps/yaak-client/lib/resolvedModelName.ts b/apps/yaak-client/lib/resolvedModelName.ts index 1e84fc772..166c0145a 100644 --- a/apps/yaak-client/lib/resolvedModelName.ts +++ b/apps/yaak-client/lib/resolvedModelName.ts @@ -1,42 +1,9 @@ import type { AnyModel } from "@yaakapp-internal/models"; import { foldersAtom } from "@yaakapp-internal/models"; +import { resolvedModelName } from "@yaakapp-internal/ui/resolvedModelName"; import { jotaiStore } from "./jotai"; -export function resolvedModelName(r: AnyModel | null): string { - if (r == null) return ""; - - if (!("url" in r) || r.model === "plugin") { - return "name" in r ? r.name : ""; - } - - // Return name if it has one - if ("name" in r && r.name) { - return r.name; - } - - // Replace variable syntax with variable name - const withoutVariables = r.url.replace(/\$\{\[\s*([^\]\s]+)\s*]}/g, "$1"); - if (withoutVariables.trim() === "") { - return r.model === "http_request" - ? r.bodyType && r.bodyType === "graphql" - ? "GraphQL Request" - : "HTTP Request" - : r.model === "websocket_request" - ? "WebSocket Request" - : "gRPC Request"; - } - - // GRPC gets nice short names - if (r.model === "grpc_request" && r.service != null && r.method != null) { - const shortService = r.service.split(".").pop(); - return `${shortService}/${r.method}`; - } - - // Strip unnecessary protocol - const withoutProto = withoutVariables.replace(/^(http|https|ws|wss):\/\//, ""); - - return withoutProto; -} +export { resolvedModelName }; export function resolvedModelNameWithFolders(model: AnyModel | null): string { return resolvedModelNameWithFoldersArray(model).join(" / "); diff --git a/apps/yaak-client/lib/responseBody.ts b/apps/yaak-client/lib/responseBody.ts index bc0fe23b6..3bfdfa8b8 100644 --- a/apps/yaak-client/lib/responseBody.ts +++ b/apps/yaak-client/lib/responseBody.ts @@ -3,7 +3,9 @@ import type { FilterResponse } from "@yaakapp-internal/plugins"; import type { ServerSentEvent, SseSummary } from "@yaakapp-internal/sse"; import { candidateJsonPayloadsFromSseText, computeSseSummary } from "@yaakapp-internal/sse"; import { rpc } from "./rpc"; +import { decodeBodyText } from "@yaakapp-internal/lib/decodeBodyText"; import { platform } from "@yaakapp-internal/platform"; +import { getContentTypeFromHeaders } from "./model_util"; /** * Reading a response body means naming the response, never the file it lives @@ -74,5 +76,6 @@ export async function getResponseBodyBytes( async function getResponseBodyDecoded(response: HttpResponse): Promise { const bytes = await getResponseBodyBytes(response); - return bytes == null ? null : new TextDecoder("utf-8").decode(bytes); + if (bytes == null) return null; + return decodeBodyText(bytes, getContentTypeFromHeaders(response.headers)); } diff --git a/apps/yaak-client/lib/templateFunctionArguments.test.ts b/apps/yaak-client/lib/templateFunctionArguments.test.ts new file mode 100644 index 000000000..b1c9dae77 --- /dev/null +++ b/apps/yaak-client/lib/templateFunctionArguments.test.ts @@ -0,0 +1,263 @@ +import { readFile } from "node:fs/promises"; +import type { TemplateFunction } from "@yaakapp-internal/plugins"; +import type { FnArg, Tokens } from "@yaakapp-internal/templates"; +import { beforeAll, describe, expect, test, vi } from "vite-plus/test"; +import { + collectEditableArgumentValues, + preserveUnchangedArgumentExpressions, +} from "./templateFunctionArguments"; + +// Use the shipped WASM parser without the browser-only `?init` loader. +let parse: (text: string) => Tokens; +beforeAll(async () => { + const base = new URL("../../../crates/yaak-templates/pkg/", import.meta.url); + const bindings = await import(new URL("yaak_templates_bg.js", base).href); + const { instance } = await WebAssembly.instantiate( + await readFile(new URL("yaak_templates_bg.wasm", base)), + { "./yaak_templates_bg.js": bindings }, + ); + bindings.__wbg_set_wasm(instance.exports); + (instance.exports.__wbindgen_start as () => void)(); + parse = bindings.parse_template; +}); + +const definition: TemplateFunction = { + name: "timestamp.iso8601", + args: [{ type: "text", name: "date", optional: true }], +}; + +function args(tokens: Tokens): FnArg[] { + const first = tokens.tokens[0]; + if (first?.type !== "tag" || first.val.type !== "fn") throw new Error("Expected a function"); + return first.val.args; +} + +function stringArg(name: string, text: string): FnArg { + return { name, value: { type: "str", text } }; +} + +// Printing is an existing async RPC. Assert the exact expression handed to it and return +// its expected text, including real parser round-tripping of quoted/escaped fixtures. +function printer(text: string) { + return vi.fn(async (tokens: Tokens) => { + expect(tokens.tokens).toEqual(parse(text).tokens.filter((t) => t.type !== "eof")); + return text; + }); +} + +describe("editable template function arguments", () => { + test.each([ + [ + "reported call", + "${[timestamp.iso8601(date=timestamp.offset(expression='-1h')) ]}", + "${[ timestamp.offset(expression='-1h') ]}", + ], + ["variable", "${[ timestamp.iso8601(date=date_var) ]}", "${[ date_var ]}"], + ["dotted variable", "${[ timestamp.iso8601(date=dates.previous) ]}", "${[ dates.previous ]}"], + [ + "nested escaping", + String.raw`${"${["} timestamp.iso8601(date=outer(value=inner(text='it\'s \\ nested'))) ]}`, + String.raw`${"${["} outer(value=inner(text='it\'s \\ nested')) ]}`, + ], + ])("loads and preserves %s on unchanged Save", async (_, source, display) => { + const initialTokens = parse(source); + const snapshot = structuredClone(initialTokens); + const print = printer(display); + const initial = await collectEditableArgumentValues(initialTokens, definition, print); + expect(initial).toEqual({ date: display }); + expect(print).toHaveBeenCalledOnce(); + + const saved = preserveUnchangedArgumentExpressions( + [stringArg("date", display)], + initialTokens, + initial, + { ...initial }, + ); + expect(saved).toEqual(args(initialTokens)); + expect(initialTokens).toEqual(snapshot); + }); + + test.each([ + String.raw`${"${["} timestamp.iso8601(date='${"${["} timestamp.offset(expression=\'-1h\') ]}') ]}`, + "${[ timestamp.iso8601(date='${[ date_var ]}') ]}", + "${[ timestamp.iso8601(date='${[ dates.previous ]}') ]}", + "${[ timestamp.iso8601(date='2026-10-27T11:00:00.000Z') ]}", + ])("leaves existing quoted arguments unchanged: %s", async (source) => { + const initialTokens = parse(source); + const print = vi.fn<(_: Tokens) => Promise>(); + const initial = await collectEditableArgumentValues(initialTokens, definition, print); + const value = args(initialTokens)[0]?.value; + if (value?.type !== "str") throw new Error("Expected a string fixture"); + expect(initial).toEqual({ date: value.text }); + expect(print).not.toHaveBeenCalled(); + expect( + preserveUnchangedArgumentExpressions( + [stringArg("date", value.text)], + initialTokens, + initial, + initial, + ), + ).toEqual(args(initialTokens)); + }); + + test("editing a sibling preserves the original expression", async () => { + const initialTokens = parse( + "${[ timestamp.format(date=timestamp.offset(expression='-1h'), format='HH:mm') ]}", + ); + const initial = await collectEditableArgumentValues( + initialTokens, + { + name: "timestamp.format", + args: [...definition.args, { type: "text", name: "format" }], + }, + printer("${[ timestamp.offset(expression='-1h') ]}"), + ); + const values: Record = { ...initial, format: "yyyy-MM-dd" }; + const saved = preserveUnchangedArgumentExpressions( + [stringArg("date", String(values.date)), stringArg("format", String(values.format))], + initialTokens, + initial, + values, + ); + expect(saved).toEqual([args(initialTokens)[0], stringArg("format", "yyyy-MM-dd")]); + }); + + test.each(["${[ timestamp.offset(expression='-2h') ]}", "2026-10-26", ""])( + "intentional edits use the form's quoted-string convention: %s", + async (edited) => { + const initialTokens = parse("${[ timestamp.iso8601(date=date_var) ]}"); + const initial = await collectEditableArgumentValues( + initialTokens, + definition, + printer("${[ date_var ]}"), + ); + const changed = [stringArg("date", edited)]; + expect( + preserveUnchangedArgumentExpressions(changed, initialTokens, initial, { date: edited }), + ).toEqual(changed); + // Returning the input to its original text restores the original expression type. + expect( + preserveUnchangedArgumentExpressions( + [stringArg("date", String(initial.date))], + initialTokens, + initial, + { ...initial }, + ), + ).toEqual(args(initialTokens)); + }, + ); + + test("does not resurrect removed or explicitly cleared expression arguments", async () => { + const initialTokens = parse("${[ timestamp.iso8601(date=date_var) ]}"); + const initial = await collectEditableArgumentValues( + initialTokens, + definition, + printer("${[ date_var ]}"), + ); + expect(preserveUnchangedArgumentExpressions([], initialTokens, initial, {})).toEqual([]); + const nullArg: FnArg = { name: "date", value: { type: "null" } }; + expect( + preserveUnchangedArgumentExpressions([nullArg], initialTokens, initial, { date: "__NULL__" }), + ).toEqual([nullArg]); + // DynamicForm clears some inputs by leaving a key with undefined; the dialog turns it into ''. + expect( + preserveUnchangedArgumentExpressions([stringArg("date", "")], initialTokens, initial, {}), + ).toEqual([stringArg("date", "")]); + expect( + preserveUnchangedArgumentExpressions([stringArg("date", "")], initialTokens, {}, {}), + ).toEqual([stringArg("date", "")]); + }); + + test("keeps absence, defaults, explicit empty strings, and false distinct", async () => { + const fn: TemplateFunction = { + name: "test", + args: [ + { type: "text", name: "absent" }, + { type: "text", name: "defaulted", defaultValue: "fallback" }, + { type: "text", name: "empty", defaultValue: "fallback" }, + { type: "checkbox", name: "flag", defaultValue: "true" }, + { type: "checkbox", name: "defaultFlag", defaultValue: "false" }, + ], + }; + const initialTokens = parse("${[ test(empty='', flag=false) ]}"); + const print = vi.fn<(_: Tokens) => Promise>(); + const initial = await collectEditableArgumentValues(initialTokens, fn, print); + expect(initial).toEqual({ + defaulted: "fallback", + empty: "", + flag: false, + defaultFlag: "false", + }); + expect(print).not.toHaveBeenCalled(); + const formArgs: FnArg[] = [ + stringArg("defaulted", "fallback"), + stringArg("empty", ""), + { name: "flag", value: { type: "bool", value: false } }, + stringArg("defaultFlag", "false"), + ]; + expect(preserveUnchangedArgumentExpressions(formArgs, initialTokens, initial, initial)).toEqual( + formArgs, + ); + }); + + test("an expression overrides defaults, including inputs in a group", async () => { + const initialTokens = parse("${[ test(date=dates.previous) ]}"); + const initial = await collectEditableArgumentValues( + initialTokens, + { + name: "test", + args: [ + { type: "h_stack", inputs: [{ type: "text", name: "date", defaultValue: "fallback" }] }, + ], + }, + printer("${[ dates.previous ]}"), + ); + expect(initial).toEqual({ date: "${[ dates.previous ]}" }); + }); + + test("explicit null keeps the existing default and omission behavior", async () => { + const initialTokens = parse("${[ test(defaulted=null, omitted=null) ]}"); + const print = vi.fn<(_: Tokens) => Promise>(); + const initial = await collectEditableArgumentValues( + initialTokens, + { + name: "test", + args: [ + { type: "text", name: "defaulted", defaultValue: "fallback" }, + { type: "text", name: "omitted" }, + ], + }, + print, + ); + expect(initial).toEqual({ defaulted: "fallback" }); + expect(print).not.toHaveBeenCalled(); + expect( + preserveUnchangedArgumentExpressions( + [stringArg("defaulted", "fallback")], + initialTokens, + initial, + initial, + ), + ).toEqual([stringArg("defaulted", "fallback")]); + }); + + test("keeps a deeply nested expression's AST without adding string wrappers", async () => { + // This shape reaches the native renderer's depth limit. A quoted wrapper around + // the date expression would exceed it, even though shallow calls render identically. + const expression = + "timestamp.iso8601(date=".repeat(44) + "'2026-10-26T10:20:30.000Z'" + ")".repeat(44); + const initialTokens = parse(`\${[ timestamp.iso8601(date=${expression}) ]}`); + const initial = await collectEditableArgumentValues( + initialTokens, + definition, + printer(`\${[ ${expression} ]}`), + ); + const saved = preserveUnchangedArgumentExpressions( + [stringArg("date", String(initial.date))], + initialTokens, + initial, + initial, + ); + expect(saved).toEqual(args(initialTokens)); + }); +}); diff --git a/apps/yaak-client/lib/templateFunctionArguments.ts b/apps/yaak-client/lib/templateFunctionArguments.ts new file mode 100644 index 000000000..95aa9173d --- /dev/null +++ b/apps/yaak-client/lib/templateFunctionArguments.ts @@ -0,0 +1,59 @@ +import type { TemplateFunction } from "@yaakapp-internal/plugins"; +import type { FnArg, Tokens } from "@yaakapp-internal/templates"; +import { collectArgumentValues } from "../components/core/Editor/twig/util"; + +type ArgumentValues = Record; + +function functionArgs(tokens: Tokens): FnArg[] { + const first = tokens.tokens[0]; + return first?.type === "tag" && first.val.type === "fn" ? first.val.args : []; +} + +/** Adapt expressions to the template text that the form's editors understand. */ +export async function collectEditableArgumentValues( + tokens: Tokens, + templateFunction: TemplateFunction, + tokensToString: (tokens: Tokens) => Promise, +): Promise { + const first = tokens.tokens[0]; + if (first?.type !== "tag" || first.val.type !== "fn") { + return collectArgumentValues(tokens, templateFunction); + } + + const args = await Promise.all( + first.val.args.map(async (arg): Promise => { + if (arg.value.type !== "fn" && arg.value.type !== "var") return arg; + const text = await tokensToString({ tokens: [{ type: "tag", val: arg.value }] }); + return { ...arg, value: { type: "str", text } }; + }), + ); + + return collectArgumentValues( + { tokens: [{ ...first, val: { ...first.val, args } }] }, + templateFunction, + ); +} + +/** + * Displaying an expression as template text must not change its stored type: a string + * wrapper adds rendering depth. Only deliberately changed inputs use the form's new values. + */ +export function preserveUnchangedArgumentExpressions( + args: FnArg[], + initialTokens: Tokens, + initialValues: ArgumentValues, + values: ArgumentValues, +): FnArg[] { + const initialArgs = functionArgs(initialTokens); + return args.map((arg) => { + const initial = initialArgs.find((a) => a.name === arg.name); + if ( + (initial?.value.type === "fn" || initial?.value.type === "var") && + Object.hasOwn(initialValues, arg.name) && + values[arg.name] === initialValues[arg.name] + ) { + return initial; + } + return arg; + }); +} diff --git a/apps/yaak-client/lib/templateTags.test.ts b/apps/yaak-client/lib/templateTags.test.ts new file mode 100644 index 000000000..876af2cf4 --- /dev/null +++ b/apps/yaak-client/lib/templateTags.test.ts @@ -0,0 +1,176 @@ +/* oxlint-disable no-template-curly-in-string */ + +import { describe, expect, test } from "vite-plus/test"; +import { findTemplateTags, replaceTemplateTags } from "./templateTags"; + +/** Seeded so a fuzz failure reproduces. */ +function random(seed: number): () => number { + let state = seed; + return () => { + state = (state + 0x6d2b79f5) | 0; + let t = Math.imul(state ^ (state >>> 15), 1 | state); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function millis(run: () => void): number { + const started = performance.now(); + run(); + return performance.now() - started; +} + +describe("findTemplateTags", () => { + test("finds a simple tag", () => { + expect(findTemplateTags("${[ my_var ]}")).toEqual([{ start: 0, end: 13, inner: " my_var " }]); + }); + + test("finds a tag surrounded by text", () => { + const text = 'a="${[ my_var ]}"'; + expect(findTemplateTags(text)).toEqual([{ start: 3, end: 16, inner: " my_var " }]); + expect(text.slice(3, 16)).toEqual("${[ my_var ]}"); + }); + + test("finds an empty tag", () => { + expect(findTemplateTags("${[]}")).toEqual([{ start: 0, end: 5, inner: "" }]); + }); + + test("keeps a quoted `]}` inside the tag", () => { + const text = "${[ fn(a='x]}y') ]}"; + expect(findTemplateTags(text)).toEqual([ + { start: 0, end: text.length, inner: " fn(a='x]}y') " }, + ]); + }); + + test("keeps an escaped quote inside the string", () => { + const text = "${[ fn(a='it\\'s ]}') ]}"; + expect(findTemplateTags(text)).toEqual([ + { start: 0, end: text.length, inner: " fn(a='it\\'s ]}') " }, + ]); + }); + + test("keeps a b64 string in one tag", () => { + const text = "${[ b64'Zm9v' ]}"; + expect(findTemplateTags(text)).toEqual([{ start: 0, end: text.length, inner: " b64'Zm9v' " }]); + }); + + test("closes at the first `]}` when a quote is left unterminated", () => { + const text = "${[ fn(a='unterminated ]} rest"; + expect(findTemplateTags(text)).toEqual([{ start: 0, end: 25, inner: " fn(a='unterminated " }]); + expect(text.slice(0, 25)).toEqual("${[ fn(a='unterminated ]}"); + }); + + test("finds two tags with text between them", () => { + expect(findTemplateTags("one ${[ a ]} two ${[ b ]} three")).toEqual([ + { start: 4, end: 12, inner: " a " }, + { start: 17, end: 25, inner: " b " }, + ]); + }); + + test("finds two adjacent tags", () => { + expect(findTemplateTags("${[ a ]} ${[ b ]}")).toEqual([ + { start: 0, end: 8, inner: " a " }, + { start: 9, end: 17, inner: " b " }, + ]); + }); + + test("finds two tags that each quote a `]}`", () => { + const text = "${[ a(x='1]}') ]} and ${[ b(y='2]}') ]}"; + expect(findTemplateTags(text)).toEqual([ + { start: 0, end: 17, inner: " a(x='1]}') " }, + { start: 22, end: 39, inner: " b(y='2]}') " }, + ]); + }); + + test("finds nothing in an unclosed tag", () => { + expect(findTemplateTags("${[ no close")).toEqual([]); + }); + + test("finds nothing in text without tags", () => { + expect(findTemplateTags('{"a": "${b}", "c": 1}')).toEqual([]); + }); + + test("treats a nested `${[` as ordinary content", () => { + expect(findTemplateTags("${[ a ${[ b ]}")).toEqual([{ start: 0, end: 14, inner: " a ${[ b " }]); + }); + + test("finishes fast on a long run of quotes", () => { + const text = `\${[${"'".repeat(20_000)} ]}`; + let matches: unknown; + const elapsed = millis(() => { + matches = findTemplateTags(text); + }); + expect(matches).toEqual([{ start: 0, end: text.length, inner: `${"'".repeat(20_000)} ` }]); + expect(elapsed).toBeLessThan(200); + }); + + test("finishes fast on a long run of unpairable quotes", () => { + // Every quote is escaped, so none of them opens a string that closes + const text = `\${[${"\\'".repeat(20_000)} ]}`; + let matches: unknown; + const elapsed = millis(() => { + matches = findTemplateTags(text); + }); + expect(matches).toHaveLength(1); + expect(elapsed).toBeLessThan(200); + }); + + test("finishes fast on a long run of openers that never close", () => { + const text = "${[".repeat(20_000); + let matches: unknown; + const elapsed = millis(() => { + matches = findTemplateTags(text); + }); + expect(matches).toEqual([]); + expect(elapsed).toBeLessThan(200); + }); + + test("never throws, and always reports real bounds", () => { + const alphabet = ["$", "{", "[", "]", "}", "'", "\\", "a", "b"]; + const next = random(0x5eed); + + for (let n = 0; n < 2_000; n++) { + let text = ""; + const length = Math.floor(next() * 31); + for (let i = 0; i < length; i++) { + text += alphabet[Math.floor(next() * alphabet.length)]; + } + + let matches: ReturnType = []; + expect(() => { + matches = findTemplateTags(text); + }, text).not.toThrow(); + + let previousEnd = 0; + for (const match of matches) { + expect(text.slice(match.start, match.start + 3), text).toEqual("${["); + expect(text.slice(match.end - 2, match.end), text).toEqual("]}"); + expect(text.slice(match.start + 3, match.end - 2), text).toEqual(match.inner); + expect(match.start, text).toBeGreaterThanOrEqual(previousEnd); + previousEnd = match.end; + } + } + }); +}); + +describe("replaceTemplateTags", () => { + test("leaves text without tags alone", () => { + expect(replaceTemplateTags('{"a": 1}', () => "x")).toEqual('{"a": 1}'); + }); + + test("replaces each tag with its trimmed contents", () => { + expect(replaceTemplateTags("${[ a ]}/x/${[ b ]}", (m) => m.inner.trim())).toEqual("a/x/b"); + }); + + test("keeps the replaced length when padding", () => { + const tag = "${[ fn(arg='x]}y') ]}"; + const text = `{"a": "${tag}"}`; + const replaced = replaceTemplateTags(text, (m) => "1".repeat(m.end - m.start)); + expect(replaced).toEqual(`{"a": "${"1".repeat(tag.length)}"}`); + expect(replaced).toHaveLength(text.length); + }); + + test("leaves an unclosed tag in place", () => { + expect(replaceTemplateTags("${[ oops", () => "x")).toEqual("${[ oops"); + }); +}); diff --git a/apps/yaak-client/lib/templateTags.ts b/apps/yaak-client/lib/templateTags.ts new file mode 100644 index 000000000..b105ce627 --- /dev/null +++ b/apps/yaak-client/lib/templateTags.ts @@ -0,0 +1,2 @@ +export type { TemplateTagMatch } from "@yaakapp-internal/ui/templateTags"; +export { findTemplateTags, replaceTemplateTags } from "@yaakapp-internal/ui/templateTags"; diff --git a/apps/yaak-client/lib/toast.tsx b/apps/yaak-client/lib/toast.tsx index 613f65814..737fdec04 100644 --- a/apps/yaak-client/lib/toast.tsx +++ b/apps/yaak-client/lib/toast.tsx @@ -1,50 +1,25 @@ -import { atom } from "jotai"; -import type { ToastInstance } from "../components/Toasts"; -import { generateId } from "./generateId"; +// The toast store alone, without the components, which tests in a plain runtime can't load +import { + hideToast as hideToastIn, + hideToastById as hideToastByIdIn, + type ShowToastArgs, + showToast as showToastIn, + type ToastInstance, +} from "@yaakapp-internal/ui/toast"; import { jotaiStore } from "./jotai"; -export const toastsAtom = atom([]); +export { toastsAtom } from "@yaakapp-internal/ui/toast"; -export function showToast({ - id, - timeout = 5000, - ...props -}: Omit & { - id?: ToastInstance["id"]; - timeout?: ToastInstance["timeout"]; -}) { - id = id ?? generateId(); - const uniqueKey = generateId(); - - const toasts = jotaiStore.get(toastsAtom); - const toastWithSameId = toasts.find((t) => t.id === id); - - let delay = 0; - if (toastWithSameId) { - hideToast(toastWithSameId); - // Allow enough time for old toast to animate out - delay = 200; - } - - setTimeout(() => { - const newToast: ToastInstance = { id, uniqueKey, timeout, ...props }; - jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]); - }, delay); - - return id; +export function showToast(args: ShowToastArgs) { + return showToastIn(jotaiStore, args); } export function hideToastById(id: string) { - const toast = jotaiStore.get(toastsAtom).find((t) => t.id === id); - if (toast) hideToast(toast); + hideToastByIdIn(jotaiStore, id); } export function hideToast(toHide: ToastInstance) { - jotaiStore.set(toastsAtom, (all) => { - const t = all.find((t) => t.uniqueKey === toHide.uniqueKey); - t?.onClose?.(); - return all.filter((t) => t.uniqueKey !== toHide.uniqueKey); - }); + hideToastIn(jotaiStore, toHide); } export function showErrorToast({ diff --git a/apps/yaak-client/package.json b/apps/yaak-client/package.json index c42d468b9..3edfa72a9 100644 --- a/apps/yaak-client/package.json +++ b/apps/yaak-client/package.json @@ -9,35 +9,35 @@ "lint": "tsc --noEmit" }, "dependencies": { - "@codemirror/commands": "^6.8.1", + "@codemirror/commands": "^6.11.1", "@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-markdown": "^6.3.2", "@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-yaml": "^6.1.2", - "@codemirror/language": "^6.11.0", - "@codemirror/merge": "^6.11.2", + "@codemirror/language": "^6.13.1", + "@codemirror/merge": "^6.12.3", "@codemirror/search": "^6.5.11", "@dnd-kit/core": "^6.3.1", - "@gilbarbara/deep-equal": "^0.3.1", - "@lezer/highlight": "^1.1.3", - "@lezer/lr": "^1.3.3", + "@gilbarbara/deep-equal": "^0.4.1", + "@lezer/highlight": "^1.2.5", + "@lezer/lr": "^1.4.11", "@mjackson/multipart-parser": "^0.10.1", "@replit/codemirror-emacs": "^6.1.0", "@replit/codemirror-vim": "^6.3.0", "@replit/codemirror-vscode-keymap": "^6.0.2", "@shopify/lang-jsonc": "^1.0.1", - "@tanstack/react-query": "^5.90.5", - "@tanstack/react-router": "^1.133.13", - "@tanstack/react-virtual": "^3.14.9", - "@tauri-apps/api": "^2.11.0", - "@tauri-apps/plugin-clipboard-manager": "^2.3.2", - "@tauri-apps/plugin-dialog": "^2.7.1", - "@tauri-apps/plugin-fs": "^2.5.1", - "@tauri-apps/plugin-log": "^2.8.0", - "@tauri-apps/plugin-opener": "^2.5.4", - "@tauri-apps/plugin-os": "^2.3.2", - "@tauri-apps/plugin-shell": "^2.3.5", + "@tanstack/react-query": "^5.105.0", + "@tanstack/react-router": "^1.170.46", + "@tanstack/react-virtual": "^3.14.24", + "@tauri-apps/api": "^2.12.4", + "@tauri-apps/plugin-clipboard-manager": "^2.5.0", + "@tauri-apps/plugin-dialog": "^2.8.4", + "@tauri-apps/plugin-fs": "^2.7.1", + "@tauri-apps/plugin-log": "^2.10.1", + "@tauri-apps/plugin-opener": "^2.7.2", + "@tauri-apps/plugin-os": "^2.4.0", + "@tauri-apps/plugin-shell": "^2.6.0", "buffer": "^6.0.3", "classnames": "^2.5.1", "cm6-graphql": "^0.2.1", @@ -45,60 +45,58 @@ "date-fns": "^4.1.0", "deep-equal": "^2.2.3", "eventemitter3": "^5.0.1", - "focus-trap-react": "^11.0.4", + "focus-trap-react": "^12.0.3", "fuzzbunny": "^1.0.1", - "graphql": "^16.13.1", - "hexy": "^0.3.5", + "graphql": "^17.0.2", "history": "^5.3.0", "jotai": "^2.18.0", "jotai-family": "^1.0.1", - "js-md5": "^0.8.3", + "js-md5": "^0.9.2", "jsonc-parser": "^3.3.1", - "lucide-react": "^0.525.0", - "mime": "^4.0.4", - "motion": "^12.4.7", - "nanoid": "^5.1.16", + "lucide-react": "^1.64.0", + "mime": "^4.1.2", + "motion": "^14.8.0", + "nanoid": "^6.0.2", "papaparse": "^5.4.1", "parse-color": "^1.0.0", - "react": "^19.2.0", + "react": "^19.3.1", "react-colorful": "^5.6.1", - "react-dom": "^19.2.0", + "react-dom": "^19.3.1", "react-markdown": "^10.1.0", - "react-pdf": "^10.0.1", - "react-syntax-highlighter": "^16.1.0", + "react-pdf": "^11.0.0", + "react-syntax-highlighter": "^16.1.2", "react-use": "^17.6.1", "rehype-stringify": "^10.0.1", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", "slugify": "^1.6.6", - "uuid": "^14.0.0", + "uuid": "^14.0.3", "vkbeautify": "^0.99.3", - "whatwg-mimetype": "^4.0.0", + "whatwg-mimetype": "^5.0.0", "yaml": "^2.8.3" }, "devDependencies": { - "@lezer/generator": "^1.8.0", - "@rolldown/plugin-babel": "^0.2.3", - "@tanstack/router-plugin": "^1.127.5", + "@lezer/generator": "^1.8.1", + "@rolldown/plugin-babel": "^0.3.0", + "@tanstack/router-plugin": "^1.168.48", "@types/babel__core": "^7.20.5", - "@types/node": "^24.0.13", + "@types/node": "^22.20.5", "@types/papaparse": "^5.3.16", "@types/parse-color": "^1.0.3", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@types/react-syntax-highlighter": "^15.5.13", "@types/uuid": "^10.0.0", - "@types/whatwg-mimetype": "^3.0.2", - "@vitejs/plugin-react": "^6.0.1", + "@types/whatwg-mimetype": "^5.0.0", + "@vitejs/plugin-react": "^6.1.2", "@yaakapp-internal/theme": "^1.0.0", "@yaakapp-internal/ui": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0", - "internal-ip": "^8.0.0", - "rollup": "^4.60.3", - "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9", - "vite-plugin-static-copy": "^3.3.0", - "vite-plugin-svgr": "^4.5.0", + "happy-dom": "^21.0.4", + "rollup": "^4.66.0", + "vite": "npm:@voidzero-dev/vite-plus-core@^1.4.0", + "vite-plugin-static-copy": "^4.1.1", "vite-plugin-wasm": "^3.5.0", - "vite-plus": "^0.2.9" + "vite-plus": "^1.4.0" } } diff --git a/apps/yaak-client/polyfills.test.ts b/apps/yaak-client/polyfills.test.ts new file mode 100644 index 000000000..a15b56383 --- /dev/null +++ b/apps/yaak-client/polyfills.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test, vi } from "vite-plus/test"; +import { getOrInsert, getOrInsertComputed, readableStreamValues, sumPrecise } from "./polyfills"; + +describe("getOrInsert", () => { + test("returns the existing value without replacing it", () => { + const map = new Map([["a", 1]]); + expect(getOrInsert.call(map, "a", 2)).toBe(1); + expect(map.get("a")).toBe(1); + }); + + test("inserts and returns the value for a missing key", () => { + const map = new Map(); + expect(getOrInsert.call(map, "a", 2)).toBe(2); + expect(map.get("a")).toBe(2); + }); + + test("works on a WeakMap", () => { + const key = {}; + const map = new WeakMap(); + expect(getOrInsert.call(map, key, 1)).toBe(1); + expect(getOrInsert.call(map, key, 2)).toBe(1); + }); +}); + +describe("getOrInsertComputed", () => { + test("returns the existing value without calling the callback", () => { + const map = new Map([["a", 1]]); + const callback = vi.fn(() => 2); + expect(getOrInsertComputed.call(map, "a", callback)).toBe(1); + expect(callback).not.toHaveBeenCalled(); + }); + + test("calls the callback with the key and inserts its result", () => { + const map = new Map(); + expect(getOrInsertComputed.call(map, "a", (key) => `${String(key)}!`)).toBe("a!"); + expect(map.get("a")).toBe("a!"); + }); + + test("stores what the callback returns even if the callback set the key", () => { + const map = new Map(); + const result = getOrInsertComputed.call(map, "a", () => { + map.set("a", 1); + return 2; + }); + expect(result).toBe(2); + expect(map.get("a")).toBe(2); + }); + + test("canonicalizes -0 to +0", () => { + const map = new Map(); + getOrInsertComputed.call(map, -0, (key) => { + expect(Object.is(key, 0)).toBe(true); + return 1; + }); + expect(Object.is([...map.keys()][0], 0)).toBe(true); + }); + + test("throws when the callback is not a function, even for an existing key", () => { + const map = new Map([["a", 1]]); + // @ts-expect-error testing a non-callable callback + expect(() => getOrInsertComputed.call(map, "a", 1)).toThrow(TypeError); + }); + + test("is installed on Map and WeakMap", () => { + const map = new Map(); + map.getOrInsertComputed("a", () => []).push(1); + map.getOrInsertComputed("a", () => []).push(2); + expect(map.get("a")).toEqual([1, 2]); + + const key = {}; + const weakMap = new WeakMap(); + expect(weakMap.getOrInsertComputed(key, () => 1)).toBe(1); + expect(weakMap.getOrInsert(key, 2)).toBe(1); + }); +}); + +describe("sumPrecise", () => { + test("sums without intermediate rounding", () => { + expect(sumPrecise([1e20, 0.1, -1e20])).toBe(0.1); + expect(sumPrecise([0.1, 0.2, 0.3])).toBe(0.6); + expect(sumPrecise(Array.from({ length: 10 }, () => 0.1))).toBe(1); + }); + + test("rounds half to even on the final step", () => { + expect(sumPrecise([1, 1e-16, 1e-16])).toBe(1.0000000000000002); + expect(sumPrecise([1, 2 ** -53])).toBe(1); + }); + + test("handles zeroes, infinities and NaN like the spec", () => { + expect(Object.is(sumPrecise([]), -0)).toBe(true); + expect(Object.is(sumPrecise([-0, -0]), -0)).toBe(true); + expect(Object.is(sumPrecise([-0, 0]), 0)).toBe(true); + expect(Object.is(sumPrecise([1, -1]), 0)).toBe(true); + expect(sumPrecise([1, Infinity])).toBe(Infinity); + expect(sumPrecise([1, -Infinity])).toBe(-Infinity); + expect(sumPrecise([Infinity, -Infinity])).toBeNaN(); + expect(sumPrecise([1, NaN])).toBeNaN(); + }); + + test("accepts any iterable and rejects non-numbers", () => { + expect(sumPrecise(new Set([1, 2, 3]))).toBe(6); + // @ts-expect-error testing a non-number item + expect(() => sumPrecise([1, "2"])).toThrow(TypeError); + }); + + test("is installed on Math", () => { + expect(Math.sumPrecise([1, 2, 3])).toBe(6); + }); +}); + +describe("readableStreamValues", () => { + const values = readableStreamValues; + + function streamOf(chunks: number[], onCancel?: () => void) { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + cancel: onCancel, + }); + } + + test("yields every chunk and releases the lock", async () => { + const stream = streamOf([1, 2, 3]); + const seen: number[] = []; + for await (const chunk of values.call(stream)) seen.push(chunk); + expect(seen).toEqual([1, 2, 3]); + expect(stream.locked).toBe(false); + }); + + test("cancels the stream when the loop exits early", async () => { + const onCancel = vi.fn(); + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue(1); + }, + cancel: onCancel, + }); + for await (const _ of values.call(stream)) break; + expect(onCancel).toHaveBeenCalled(); + expect(stream.locked).toBe(false); + }); + + test("leaves the stream open with preventCancel", async () => { + const onCancel = vi.fn(); + const stream = streamOf([1, 2], onCancel); + for await (const _ of values.call(stream, { preventCancel: true })) break; + expect(onCancel).not.toHaveBeenCalled(); + expect(stream.locked).toBe(false); + }); + + test("releases the lock and rethrows when cancelling rejects", async () => { + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue(1); + }, + cancel() { + throw new Error("cancel failed"); + }, + }); + await expect(async () => { + for await (const _ of values.call(stream)) break; + }).rejects.toThrow("cancel failed"); + expect(stream.locked).toBe(false); + }); +}); diff --git a/apps/yaak-client/polyfills.ts b/apps/yaak-client/polyfills.ts new file mode 100644 index 000000000..7f6171dfc --- /dev/null +++ b/apps/yaak-client/polyfills.ts @@ -0,0 +1,172 @@ +/** + * Built-ins that PDF.js's modern build calls unconditionally but the system WebKit on + * older macOS lacks: the upsert methods and `Math.sumPrecise` arrived in Safari 26.2, + * and async iteration of `ReadableStream` in Safari 27. Without them the PDF viewer + * throws as soon as it renders. Loaded first by `index.html`, and by `pdfWorker.ts` + * because the worker has its own globals. + * + * Each is defined only when missing, so newer engines keep their native versions. + */ + +type KeyedCollection = { + has(key: K): boolean; + get(key: K): V | undefined; + set(key: K, value: V): unknown; +}; + +/** https://tc39.es/proposal-upsert/#sec-map.prototype.getOrInsert */ +export function getOrInsert(this: KeyedCollection, key: K, value: V): V { + if (this.has(key)) return this.get(key) as V; + this.set(key, value); + return value; +} + +/** https://tc39.es/proposal-upsert/#sec-map.prototype.getOrInsertComputed */ +export function getOrInsertComputed( + this: KeyedCollection, + key: K, + callbackfn: (key: K) => V, +): V { + if (typeof callbackfn !== "function") { + throw new TypeError("getOrInsertComputed callback is not a function"); + } + if (this.has(key)) return this.get(key) as V; + // Map keys are canonicalized, so -0 is stored and passed on as +0 + const k = (Object.is(key, -0) ? 0 : key) as K; + const value = callbackfn(k); + // The spec sets after the callback even if the callback inserted the key itself + this.set(k, value); + return value; +} + +/** + * https://tc39.es/proposal-math-sum/ + * + * Shewchuk's exact summation with round-half-even on the final step (the algorithm + * behind Python's `math.fsum`). A finite input set whose running sum overflows + * partway through is not handled exactly, which PDF.js's byte lengths and widths + * never come near. + */ +export function sumPrecise(items: Iterable): number { + const partials: number[] = []; + let allNegativeZero = true; + let sawNaN = false; + let sawPositiveInfinity = false; + let sawNegativeInfinity = false; + + for (const item of items) { + if (typeof item !== "number") { + throw new TypeError("Math.sumPrecise requires an iterable of numbers"); + } + if (!Object.is(item, -0)) allNegativeZero = false; + if (Number.isNaN(item)) { + sawNaN = true; + } else if (item === Infinity) { + sawPositiveInfinity = true; + } else if (item === -Infinity) { + sawNegativeInfinity = true; + } else { + let x = item; + let i = 0; + for (let y of partials) { + if (Math.abs(x) < Math.abs(y)) [x, y] = [y, x]; + const hi = x + y; + const lo = y - (hi - x); + if (lo !== 0) partials[i++] = lo; + x = hi; + } + partials.length = i; + partials.push(x); + } + } + + if (sawNaN || (sawPositiveInfinity && sawNegativeInfinity)) return NaN; + if (sawPositiveInfinity) return Infinity; + if (sawNegativeInfinity) return -Infinity; + if (allNegativeZero) return -0; + + let n = partials.length; + let hi = 0; + let lo = 0; + if (n > 0) { + hi = partials[--n] as number; + while (n > 0) { + const x = hi; + const y = partials[--n] as number; + hi = x + y; + lo = y - (hi - x); + if (lo !== 0) break; + } + const next = partials[n - 1]; + if (n > 0 && next != null && ((lo < 0 && next < 0) || (lo > 0 && next > 0))) { + const y = lo * 2; + const x = hi + y; + if (y === x - hi) hi = x; + } + } + return hi === 0 ? 0 : hi; +} + +/** https://streams.spec.whatwg.org/#rs-asynciterator */ +export async function* readableStreamValues( + this: ReadableStream, + { preventCancel = false }: { preventCancel?: boolean } = {}, +): AsyncGenerator { + const reader = this.getReader(); + let finished = false; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + finished = true; + return; + } + yield value; + } + } catch (err) { + finished = true; + throw err; + } finally { + // Leaving the loop early (break, return, throw in the body) cancels the stream. The + // lock is released even if cancelling rejects, which then propagates as in the spec. + try { + if (!finished && !preventCancel) await reader.cancel(); + } finally { + reader.releaseLock(); + } + } +} + +function define(target: object, key: PropertyKey, value: unknown) { + if (key in target) return; + Object.defineProperty(target, key, { value, writable: true, configurable: true }); +} + +define(Map.prototype, "getOrInsert", getOrInsert); +define(Map.prototype, "getOrInsertComputed", getOrInsertComputed); +define(WeakMap.prototype, "getOrInsert", getOrInsert); +define(WeakMap.prototype, "getOrInsertComputed", getOrInsertComputed); +define(Math, "sumPrecise", sumPrecise); +if (typeof ReadableStream !== "undefined") { + define(ReadableStream.prototype, "values", readableStreamValues); + define( + ReadableStream.prototype, + Symbol.asyncIterator, + Reflect.get(ReadableStream.prototype, "values"), + ); +} + +// TypeScript's lib does not describe these yet +declare global { + interface Map { + getOrInsert(key: K, value: V): V; + getOrInsertComputed(key: K, callbackfn: (key: K) => V): V; + } + interface WeakMap { + getOrInsert(key: K, value: V): V; + getOrInsertComputed(key: K, callbackfn: (key: K) => V): V; + } + interface Math { + sumPrecise(items: Iterable): number; + } +} diff --git a/apps/yaak-client/react-augment.d.ts b/apps/yaak-client/react-augment.d.ts new file mode 100644 index 000000000..4aaa3533c --- /dev/null +++ b/apps/yaak-client/react-augment.d.ts @@ -0,0 +1,8 @@ +import "react"; + +declare module "react" { + interface HTMLAttributes { + // Not in @types/react yet; opts editable elements out of macOS Writing Tools + writingsuggestions?: "true" | "false"; + } +} diff --git a/apps/yaak-client/routes/__root.tsx b/apps/yaak-client/routes/__root.tsx index 14ad2227a..1c547e23a 100644 --- a/apps/yaak-client/routes/__root.tsx +++ b/apps/yaak-client/routes/__root.tsx @@ -1,12 +1,15 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { createRootRoute, Outlet } from "@tanstack/react-router"; import { platform } from "@yaakapp-internal/platform"; +import { HotkeyProvider } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { Provider as JotaiProvider } from "jotai"; import { LazyMotion, MotionConfig } from "motion/react"; -import { lazy, Suspense } from "react"; +import { lazy, type ReactNode, Suspense } from "react"; +import { ClientEditorHost } from "../components/ClientEditorHost"; import { GlobalHooks } from "../components/GlobalHooks"; import RouteError from "../components/RouteError"; +import { useHotkeyContextValue } from "../hooks/useHotKey"; import { jotaiStore } from "../lib/jotai"; import { queryClient } from "../lib/queryClient"; @@ -27,22 +30,30 @@ function RouteComponent() { return ( - - - - - - - - - - - + + + + + + + + + + + + + + + ); } +function Hotkeys({ children }: { children: ReactNode }) { + return {children}; +} + function Layout() { return (
): IndexSearchSchema => + search.home === true ? { home: true } : {}, }); function RouteComponent() { - return ; + const { home } = Route.useSearch(); + return home ? : ; } diff --git a/apps/yaak-client/vite.config.ts b/apps/yaak-client/vite.config.ts index 6fb1e7b8d..40e18e6c3 100644 --- a/apps/yaak-client/vite.config.ts +++ b/apps/yaak-client/vite.config.ts @@ -1,11 +1,11 @@ // @ts-ignore import { tanstackRouter } from "@tanstack/router-plugin/vite"; import react from "@vitejs/plugin-react"; +import fs from "node:fs"; import { createRequire } from "node:module"; 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 wasm from "vite-plugin-wasm"; const require = createRequire(import.meta.url); @@ -16,6 +16,12 @@ const standardFontsDir = normalizePath( path.join(path.dirname(require.resolve("pdfjs-dist/package.json")), "standard_fonts"), ); +// The app's own icons, served as the page icon rather than copied into the client: +// the bundled app and the tab should not be able to disagree about what Yaak looks like. +const iconsDir = normalizePath( + path.join(import.meta.dirname, "../../crates-tauri/yaak-app-client/icons/release"), +); + /** * 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 @@ -23,6 +29,84 @@ const standardFontsDir = normalizePath( */ const yaakTarget = process.env.YAAK_TARGET === "web" ? "web" : "desktop"; +const webUrl = "https://web.yaak.app"; +const webDir = normalizePath(path.join(import.meta.dirname, "web")); + +/** Link previews for the hosted web app. Meaningless inside the desktop window. */ +function webHead() { + return { + name: "web-head", + transformIndexHtml() { + const title = "Yaak in your browser"; + const description = "No download, no setup. Open a tab and send your first request."; + return [ + { tag: "meta", attrs: { name: "description", content: description } }, + { tag: "meta", attrs: { property: "og:type", content: "website" } }, + { tag: "meta", attrs: { property: "og:url", content: webUrl } }, + { tag: "meta", attrs: { property: "og:title", content: title } }, + { tag: "meta", attrs: { property: "og:description", content: description } }, + { tag: "meta", attrs: { property: "og:image", content: `${webUrl}/og.png` } }, + { tag: "meta", attrs: { property: "og:image:width", content: "1200" } }, + { tag: "meta", attrs: { property: "og:image:height", content: "630" } }, + { tag: "meta", attrs: { name: "twitter:card", content: "summary_large_image" } }, + { tag: "meta", attrs: { name: "twitter:site", content: "@yaakapp" } }, + { tag: "meta", attrs: { name: "twitter:title", content: title } }, + { tag: "meta", attrs: { name: "twitter:description", content: description } }, + { tag: "meta", attrs: { name: "twitter:image", content: `${webUrl}/og.png` } }, + ]; + }, + }; +} + +/** + * Where `yaak-web` is listening, taken from the same variables that put it there. + * + * `HOST` is an instruction about what the server accepts rather than an address to + * dial, so a wildcard becomes loopback: the dev server and the send server share a + * machine. + */ +function sendServerUrl(): string { + const host = process.env.HOST?.trim(); + const port = process.env.PORT?.trim() || "9227"; + const wildcard = !host || host === "0.0.0.0" || host === "::" || host === "[::]"; + return `http://${wildcard ? "127.0.0.1" : host}:${port}`; +} + +/** + * Fails the build when a copied asset is not where the app fetches it from. + * + * `PdfViewer` and the page head address these by URL, but nothing ties those + * URLs to the copy targets below, and a target that mirrors its source path + * instead of landing at the root still builds cleanly — the miss only shows up + * as a 404 once the app runs. + */ +function verifyServedAssets(expected: string[]) { + let outDir = ""; + return { + name: "verify-served-assets", + apply: "build" as const, + configResolved(config: { root: string; build: { outDir: string } }) { + outDir = path.resolve(config.root, config.build.outDir); + }, + // After viteStaticCopy, which writes on `writeBundle`. + closeBundle() { + const missing = expected.filter((asset) => { + const full = path.join(outDir, asset); + if (!fs.existsSync(full)) return true; + const stat = fs.statSync(full); + return stat.isDirectory() && fs.readdirSync(full).length === 0; + }); + if (missing.length > 0) { + throw new Error( + `Copied assets missing from ${outDir}: ${missing.join(", ")}. ` + + `Every viteStaticCopy target needs to strip its base path, or it lands ` + + `under a copy of the directories it came from instead.`, + ); + } + }, + }; +} + // https://vitejs.dev/config/ export default defineConfig(async () => { return { @@ -56,14 +140,44 @@ export default defineConfig(async () => { generatedRouteTree: "./routeTree.gen.ts", autoCodeSplitting: true, }), - svgr(), react(), viteStaticCopy({ + // v4 matches only files and always mirrors the source tree into the + // output, so every target here needs stripBase to land where it is + // actually served from — without it these end up under a copy of the + // path they came from, and nothing fails until the request 404s. targets: [ - { src: cMapsDir, dest: "" }, - { src: standardFontsDir, dest: "" }, + { src: `${cMapsDir}/*`, dest: "cmaps", rename: { stripBase: true } }, + { + src: `${standardFontsDir}/*`, + dest: "standard_fonts", + rename: { stripBase: true }, + }, + // `/favicon.ico` is requested by browsers whether or not anything links to it, + // so it is served under that name to keep a 404 out of every console. + { + src: `${iconsDir}/icon.ico`, + dest: "", + rename: { name: "favicon.ico", stripBase: true }, + }, + { + src: `${iconsDir}/128x128.png`, + dest: "", + rename: { name: "icon-128.png", stripBase: true }, + }, + ...(yaakTarget === "web" + ? [{ src: `${webDir}/og.png`, dest: "", rename: { stripBase: true } }] + : []), ], }), + ...(yaakTarget === "web" ? [webHead()] : []), + verifyServedAssets([ + "cmaps", + "standard_fonts", + "favicon.ico", + "icon-128.png", + ...(yaakTarget === "web" ? ["og.png"] : []), + ]), ], build: { target: "esnext", @@ -84,8 +198,28 @@ export default defineConfig(async () => { }, clearScreen: false, server: { + // `HOST` names the interface, as it does most places: unset leaves Vite on + // loopback, `0.0.0.0` exposes it for reaching the dev server from another + // device. + host: process.env.HOST, + // Vite refuses a `Host` it does not recognise, which stops a page on a name the + // attacker controls from rebinding that name here and driving `/v1` as its own + // origin. Addresses are allowed already; only names need listing, so reaching + // this as `dev-box.example` means naming it. Deliberately not widened to "any + // host when exposed": the proxy below leads to an unauthenticated sender. + allowedHosts: process.env.ALLOWED_HOSTS?.split(",") + .map((h) => h.trim()) + .filter(Boolean), port: parseInt(process.env.YAAK_CLIENT_DEV_PORT ?? process.env.YAAK_DEV_PORT ?? "1420", 10), strictPort: true, + // A web dev server is one origin, the way the built app is: `/v1` is passed + // through to `yaak-web` rather than the tab being told to call it directly. + // That is one address to open instead of two, no CORS in the loop, and a + // dev build that sends exactly the way a production build does. + proxy: + yaakTarget === "web" + ? { "/v1": { target: sendServerUrl(), changeOrigin: true } } + : undefined, }, envPrefix: ["VITE_", "TAURI_"], }; diff --git a/apps/yaak-client/web/og.html b/apps/yaak-client/web/og.html new file mode 100644 index 000000000..cb78ddb39 --- /dev/null +++ b/apps/yaak-client/web/og.html @@ -0,0 +1,129 @@ + + + + + + + + + + + +
+
+
+ + Yaak +
+

Your favorite API client
in your browser

+

No download, no setup. Open a tab and send your first request.

+
+ GET + https://web.yaak.app + 200 OK +
+
+ + diff --git a/apps/yaak-client/web/og.png b/apps/yaak-client/web/og.png new file mode 100644 index 000000000..efe79e66a Binary files /dev/null and b/apps/yaak-client/web/og.png differ diff --git a/apps/yaak-proxy/assets/login-items.png b/apps/yaak-proxy/assets/login-items.png new file mode 100644 index 000000000..fad5628f6 Binary files /dev/null and b/apps/yaak-proxy/assets/login-items.png differ diff --git a/apps/yaak-proxy/components/AppIcon.tsx b/apps/yaak-proxy/components/AppIcon.tsx new file mode 100644 index 000000000..e132ee021 --- /dev/null +++ b/apps/yaak-proxy/components/AppIcon.tsx @@ -0,0 +1,49 @@ +import classNames from "classnames"; +import { useEffect, useState } from "react"; +import { rpc } from "../lib/rpc"; + +/** + * Icons by app path, as data URLs once loaded, for the whole session. Plain rather than a query + * per icon, since every new row in the list shows one. + */ +const icons = new Map(); +const loading = new Map>(); + +function loadIcon(path: string): Promise { + let promise = loading.get(path); + if (promise == null) { + promise = rpc("app_icon", { path }) + .then((png) => (png == null ? null : `data:image/png;base64,${png}`)) + .catch(() => null) + .then((url) => { + icons.set(path, url); + return url; + }); + loading.set(path, promise); + } + return promise; +} + +/** An app's icon, read once per path for the whole session */ +export function AppIcon({ path, className }: { path: string; className?: string }) { + const [url, setUrl] = useState(() => icons.get(path)); + useEffect(() => { + if (icons.has(path)) { + setUrl(icons.get(path)); + return; + } + let current = true; + void loadIcon(path).then((loaded) => { + if (current) setUrl(loaded); + }); + return () => { + current = false; + }; + }, [path]); + + return url == null ? ( + + ) : ( + + ); +} diff --git a/apps/yaak-proxy/components/ClearFlowsButton.tsx b/apps/yaak-proxy/components/ClearFlowsButton.tsx new file mode 100644 index 000000000..eb7cbdf25 --- /dev/null +++ b/apps/yaak-proxy/components/ClearFlowsButton.tsx @@ -0,0 +1,49 @@ +import { Dropdown, type DropdownItem, IconButton } from "@yaakapp-internal/ui"; +import { useAtomValue } from "jotai"; +import { useRpcMutation } from "../hooks/useRpcMutation"; +import { flowTotalAtom } from "../lib/store"; + +/** Deletes every flow, once confirmed in its menu, which its hotkey opens too */ +export function ClearFlowsButton() { + const total = useAtomValue(flowTotalAtom); + const clear = useRpcMutation("clear_flows"); + + const items: DropdownItem[] = [ + { + type: "content", + label: ( +
+
+ {total === 1 ? "Clear 1 flow?" : `Clear all ${total} flows?`} +
+
+ {clear.error != null ? ( + {clear.error.message} + ) : ( + "They are deleted from disk with their bodies, for good." + )} +
+
+ ), + }, + { type: "separator" }, + { + label: "Clear", + icon: "trash", + color: "danger", + onSelect: () => clear.mutate({}), + }, + ]; + + return ( + + + + ); +} diff --git a/apps/yaak-proxy/components/ExchangesTable.tsx b/apps/yaak-proxy/components/ExchangesTable.tsx deleted file mode 100644 index d87d5d551..000000000 --- a/apps/yaak-proxy/components/ExchangesTable.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import type { HttpExchange, ProxyHeader } from "@yaakapp-internal/proxy-lib"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, - TruncatedWideTableCell, -} from "@yaakapp-internal/ui"; -import classNames from "classnames"; - -interface Props { - exchanges: HttpExchange[]; - style?: React.CSSProperties; - className?: string; -} - -export function ExchangesTable({ exchanges, style, className }: Props) { - if (exchanges.length === 0) { - return

No traffic yet

; - } - - return ( -
- - - - Method - URL - Status - Type - - - - {exchanges.map((ex) => ( - - {ex.method} - - {ex.url} - - - - - - {getContentType(ex.resHeaders)} - - - ))} - -
-
- ); -} - -function StatusBadge({ status, error }: { status: number | null; error: string | null }) { - if (error) return Error; - if (status == null) return —; - - const color = - status >= 500 - ? "text-danger" - : status >= 400 - ? "text-warning" - : status >= 300 - ? "text-notice" - : "text-success"; - - return {status}; -} - -function getContentType(headers: ProxyHeader[]): string { - const ct = headers.find((h) => h.name.toLowerCase() === "content-type")?.value; - if (ct == null) return "—"; - // Strip parameters (e.g. "; charset=utf-8") - return ct.split(";")[0]?.trim() ?? ct; -} diff --git a/apps/yaak-proxy/components/FirstRun.tsx b/apps/yaak-proxy/components/FirstRun.tsx new file mode 100644 index 000000000..987bb07b4 --- /dev/null +++ b/apps/yaak-proxy/components/FirstRun.tsx @@ -0,0 +1,225 @@ +import { Button, Icon, LoadingIcon } from "@yaakapp-internal/ui"; +import classNames from "classnames"; +import { useAtomValue } from "jotai"; +import type { ReactNode } from "react"; +import { useRpcMutation } from "../hooks/useRpcMutation"; +import { proxyStateAtom } from "../lib/store"; +import { useCopyEnvironment, useProxySetup, useSystemProxySwitch } from "./ProxyStatus"; + +type StepState = "todo" | "next" | "wait" | "done" | "fail"; + +/** + * The list's empty state until traffic arrives: trust the certificate, turn on the system proxy, + * then see traffic. Not dismissable and nothing remembered; the first flow replaces it. Copy + * environment beside it serves someone who wants one terminal proxied and nothing system-wide. + */ +export function FirstRun() { + const proxyState = useAtomValue(proxyStateAtom); + const setup = useProxySetup(); + const { caStatus, trust, systemProxy, setSystemProxy } = setup; + const systemSwitch = useSystemProxySwitch(setup); + const execute = useRpcMutation("execute_action"); + const retry = useRpcMutation("take_over"); + const copyEnvironment = useCopyEnvironment(); + + const running = proxyState?.state === "running"; + // Changed only by the process that owns the engine, so not from here while the CLI does. + const otherOwner = proxyState?.otherOwner != null; + const recording = proxyState?.recording !== false; + const trusted = caStatus.data?.trusted === true; + const systemSupported = systemProxy.data?.supported !== false; + const systemOn = systemProxy.data?.enabled === true; + + const trustState: StepState = trusted + ? "done" + : trust.isPending + ? "wait" + : trust.error != null + ? "fail" + : "next"; + const systemState: StepState = setSystemProxy.isPending + ? "wait" + : systemOn + ? "done" + : systemSwitch.failed + ? "fail" + : trusted + ? "next" + : "todo"; + const ready = trusted && (systemOn || !systemSupported); + const trafficState: StepState = !running && !otherOwner ? "fail" : ready ? "wait" : "todo"; + + return ( +
+
+
Set up Yaak Proxy
+
+ Three steps, once. Afterwards every app on this Mac sends its traffic here, and HTTPS + reads as plain text. +
+
+ trust.mutate({})} + > + {trustState === "fail" ? "Try again" : "Trust certificate"} + + ) : null + } + /> + {systemSupported && ( + setSystemProxy.mutate({ enabled: true })} + > + {systemState === "fail" ? "Try again" : "Turn on"} + + ) : null + } + /> + )} + retry.mutate({})} + > + Retry + + ) : !recording && running ? ( + + ) : null + } + /> +
+
+ Only need one terminal or one app? Copy the environment instead. + +
+
+
+ ); +} + +function Step({ + n, + state, + title, + description, + action, +}: { + n: number; + state: StepState; + title: string; + description: ReactNode; + action: ReactNode; +}) { + return ( +
+ +
+
+ {title} +
+
+ {description} +
+
+
{action}
+
+ ); +} + +function StepMark({ n, state }: { n: number; state: StepState }) { + const base = "w-[1.4rem] h-[1.4rem] rounded-full grid place-items-center text-xs"; + switch (state) { + case "done": + return ( + + + + ); + case "fail": + return ( + + + + ); + case "wait": + return ( + + + + ); + case "next": + return {n}; + case "todo": + return {n}; + } +} diff --git a/apps/yaak-proxy/components/FlowBodyViewer.tsx b/apps/yaak-proxy/components/FlowBodyViewer.tsx new file mode 100644 index 000000000..b86c7e2b8 --- /dev/null +++ b/apps/yaak-proxy/components/FlowBodyViewer.tsx @@ -0,0 +1,227 @@ +import { useQuery } from "@tanstack/react-query"; +import { formatSize } from "@yaakapp-internal/lib/formatSize"; +import type { Flow, FlowBody } from "@yaakapp-internal/proxy-lib"; +import { + Banner, + Button, + CsvViewer, + HexdumpViewer, + Icon, + ImageViewer, + languageFromContentType, + LoadingIcon, + MultipartViewer, + SvgViewer, + TextViewer, + WebPageViewer, +} from "@yaakapp-internal/ui"; +import type { CodeEditorProps } from "@yaakapp-internal/ui/editor"; +import type { ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + bodyAsText, + bodyImageType, + filterText, + findHeader, + flowBodyQueryOptions, + formatText, + mimeEssence, +} from "../lib/body"; +import { useSaveFlowBody } from "../lib/flowActions"; + +interface Props { + flow: Flow; + which: FlowBody; + pretty: boolean; +} + +export function useFlowBody(flow: Flow, which: FlowBody, enabled = true) { + return useQuery({ ...flowBodyQueryOptions(flow, which), enabled }); +} + +export function FlowBodyViewer({ flow, which, pretty }: Props) { + const body = useFlowBody(flow, which); + + if (body.isPending) { + return ( + + + + ); + } + if (body.error) { + return {body.error.message}; + } + + const { bytes, truncated, storedSize, decodeError } = body.data; + if (bytes.length === 0 && decodeError == null) { + return {which === "request" ? "No request body" : "Empty"}; + } + + const headers = which === "request" ? flow.requestHeaders : flow.responseHeaders; + const contentType = findHeader(headers, "content-type"); + const mime = mimeEssence(contentType) ?? ""; + const wireSize = which === "request" ? flow.requestSize : flow.responseSize; + const wireTruncated = which === "request" ? flow.requestTruncated : flow.responseTruncated; + // An image by its bytes or type, else text whatever the content type says, so a page sent with + // an odd one still reads: `viewerText`'s rule. + const imageType = decodeError == null ? bodyImageType(bytes, truncated, contentType) : null; + const text = + decodeError == null && imageType == null ? bodyAsText(bytes, truncated, contentType) : null; + + let viewer: ReactNode; + if (decodeError != null) { + // What failed to decode is as it was on the wire, compressed, so never text. + viewer = ; + } else if (mime.startsWith("multipart/") && pretty) { + const boundary = contentType?.match(/;\s*boundary="?([^";]+)"?/i)?.[1] ?? "unknown"; + viewer = ( + + ); + } else if (imageType != null) { + viewer = ; + } else if (text == null) { + viewer = ; + } else if (mime.startsWith("image/svg")) { + viewer = ; + } else if (/csv|tab-separated/.test(mime) && pretty) { + viewer = ; + } else { + const language = languageFromContentType(contentType, text); + if (language === "html" && pretty && which === "response") { + viewer = ; + } else { + viewer = ( + + ); + } + } + + if (!truncated && decodeError == null) return viewer; + + return ( +
+ {decodeError != null && ( + + {decodeError}. Showing the body as it was on the wire. + + )} + {truncated && ( + + {wireTruncated || decodeError != null + ? `Showing the first ${formatSize(storedSize)} of ${formatSize(wireSize)}, the capture limit. The app received all of it.` + : `Showing the first ${formatSize(bytes.length)} of the decoded body, the capture limit. The app received all of it.`} + + )} +
{viewer}
+
+ ); +} + +/** Bytes shown in the hexdump; the rest are in the saved file */ +const HEXDUMP_LIMIT = 256 * 1024; + +/** A body that isn't text: a hexdump, and a way to save it */ +function BinaryBody({ + flow, + which, + bytes, +}: { + flow: Flow; + which: FlowBody; + bytes: Uint8Array; +}) { + const saveBody = useSaveFlowBody(); + const shown = useMemo(() => bytes.subarray(0, HEXDUMP_LIMIT), [bytes]); + + return ( +
+
+ + Binary body, {formatSize(bytes.length)} + {shown.length < bytes.length && `, first ${formatSize(shown.length)} shown`} + + +
+ +
+ ); +} + +/** A text body, filtered with the syntax for the language detected from it, not just its header */ +function FlowTextViewer({ + flow, + which, + text, + language, + pretty, +}: Props & { text: string; language: CodeEditorProps["language"] }) { + const runFilter = useCallback( + (expression: string) => filterText(flow.id, which, expression, language), + [flow.id, which, language], + ); + + return ( + + ); +} + +function ImageBody({ bytes, mime }: { bytes: Uint8Array; mime: string }) { + const [size, setSize] = useState<{ width: number; height: number } | null>(null); + + useEffect(() => { + let cancelled = false; + createImageBitmap(new Blob([bytes], { type: mime })).then( + (bitmap) => { + if (!cancelled) setSize({ width: bitmap.width, height: bitmap.height }); + bitmap.close(); + }, + () => { + if (!cancelled) setSize(null); + }, + ); + return () => { + cancelled = true; + }; + }, [bytes, mime]); + + return ( +
+
+ +
+
+ {size != null && `${size.width} × ${size.height} • `} + {mime} • {formatSize(bytes.length)} +
+
+ ); +} + +export function EmptyState({ children }: { children: ReactNode }) { + return ( +
+
{children}
+
+ ); +} diff --git a/apps/yaak-proxy/components/FlowDetail.tsx b/apps/yaak-proxy/components/FlowDetail.tsx new file mode 100644 index 000000000..5cb5e818d --- /dev/null +++ b/apps/yaak-proxy/components/FlowDetail.tsx @@ -0,0 +1,322 @@ +import { formatSize } from "@yaakapp-internal/lib/formatSize"; +import type { Flow } from "@yaakapp-internal/proxy-lib"; +import { + Banner, + Button, + CountBadge, + Dropdown, + type DropdownItem, + formatMillis, + HStack, + HttpStatusTagRaw, + Icon, + IconButton, + SizeTag, + type TabButtonRenderProps, + type TabItem, + TabContent, + Tabs, +} from "@yaakapp-internal/ui"; +import classNames from "classnames"; +import { useAtomValue, useSetAtom } from "jotai"; +import type { CSSProperties } from "react"; +import { useMemo, useState } from "react"; +import { selectedFlowAtom } from "../lib/atoms"; +import { findHeader, mimeEssence } from "../lib/body"; +import { saveDialogFlowAtom, useFlowActions } from "../lib/flowActions"; +import { statusReason } from "../lib/statusReason"; +import { EmptyState, FlowBodyViewer, useFlowBody } from "./FlowBodyViewer"; +import { FlowHeaders, FlowQuery, queryParams } from "./FlowHeaders"; + +const TAB_RESPONSE = "response"; +const TAB_REQUEST = "request"; +const TAB_HEADERS = "headers"; +const TAB_QUERY = "query"; + +export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideBySide: boolean }) { + const flow = useAtomValue(selectedFlowAtom); + + return ( +
+
+ {flow == null ? ( + +
+ Select a flow + + Pause or resume recording + + Clear flows + +
+
+ ) : ( + + )} +
+
+ ); +} + +function FlowPane({ flow }: { flow: Flow }) { + const [pretty, setPretty] = useState(true); + const responseBody = useFlowBody(flow, "response", flow.responseTruncated); + // Failed or cancelled before any response; after the headers, the body was cut short mid-way. + const noResponse = (flow.error != null || flow.cancelled) && flow.status == null; + const mime = mimeEssence(findHeader(flow.responseHeaders, "content-type")); + const queryCount = useMemo(() => queryParams(flow.url).length, [flow.url]); + const openSave = useSetAtom(saveDialogFlowAtom); + const flowActions = useFlowActions(); + + const tabs = useMemo( + () => [ + { + value: TAB_RESPONSE, + renderButton: ({ buttonProps, isActive }) => ( + + ), + }, + { + value: TAB_REQUEST, + label: "Request", + rightSlot: flow.requestSize > 0 ? : null, + }, + { + value: TAB_HEADERS, + label: "Headers", + rightSlot: ( + + ), + }, + { + value: TAB_QUERY, + label: "Query", + rightSlot: , + }, + ], + [ + pretty, + mime, + flow.requestSize, + flow.requestHeaders.length, + flow.responseHeaders.length, + queryCount, + ], + ); + + return ( +
+
+ + {flow.cancelled && flow.status == null ? ( + Cancelled + ) : ( + + )} + {flow.elapsedMs != null && ( + <> + • + + {formatMillis(flow.elapsedMs)} + + + )} + {!noResponse && ( + <> + • + + + )} + + {flow.responseTruncated ? ( + + + {responseBody.data == null + ? "Body cut" + : `First ${formatSize(responseBody.data.storedSize)} kept`} + + ) : ( + + )} + + + + + + +
+ +
+ {flow.error != null && ( + + {flow.error} + + )} + {flow.cancelled && ( + + {flow.status == null + ? "The app cancelled the request before a response came back." + : "The app cancelled the request before the response finished."} + + )} + + + {noResponse ? ( + +
+ {flow.cancelled ? "Cancelled" : "No response"} +
+
+ {flow.cancelled + ? "The app cancelled the request before a response came back." + : "The request never got a response."}{" "} + The Request tab has what the app sent. +
+
+ ) : ( + + )} +
+ + + + + + + + + +
+
+
+ ); +} + +interface ViewModeButtonProps { + buttonProps: TabButtonRenderProps["buttonProps"]; + isActive: boolean; + pretty: boolean; + onChangePretty: (pretty: boolean) => void; + prettyLabel: string; + allowRaw: boolean; +} + +/** The client's Response tab: a click on the active tab opens its view menu */ +function ViewModeButton({ + buttonProps, + isActive, + pretty, + onChangePretty, + prettyLabel, + allowRaw, +}: ViewModeButtonProps) { + const showPretty = pretty || !allowRaw; + const items: DropdownItem[] = [ + { + label: prettyLabel, + leftSlot: , + onSelect: () => onChangePretty(true), + }, + { + label: `${prettyLabel} (Raw)`, + hidden: !allowRaw, + leftSlot: , + onSelect: () => onChangePretty(false), + }, + ]; + + return ( + + + + ); +} + +function CheckMark({ visible }: { visible: boolean }) { + return ; +} + +function Keys({ keys }: { keys: string[] }) { + return ( + + {keys.map((k) => ( + + {k} + + ))} + + ); +} diff --git a/apps/yaak-proxy/components/FlowHeaders.tsx b/apps/yaak-proxy/components/FlowHeaders.tsx new file mode 100644 index 000000000..01d8c7496 --- /dev/null +++ b/apps/yaak-proxy/components/FlowHeaders.tsx @@ -0,0 +1,109 @@ +import { formatSize } from "@yaakapp-internal/lib/formatSize"; +import type { Flow } from "@yaakapp-internal/proxy-lib"; +import { + DetailsBanner, + formatMillis, + HeadersTable, + KeyValueRow, + KeyValueRows, +} from "@yaakapp-internal/ui"; +import { useMemo } from "react"; +import { EmptyState } from "./FlowBodyViewer"; + +const startedFormat = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + timeZoneName: "short", +}); + +const bytesFormat = new Intl.NumberFormat("en-US"); + +export function FlowHeaders({ flow }: { flow: Flow }) { + const versions = + flow.upstreamHttpVersion == null + ? `${flow.clientHttpVersion} from the app` + : `${flow.clientHttpVersion} from the app, ${flow.upstreamHttpVersion} to the server`; + + return ( +
+ Info}> + + + {startedFormat.format(new Date(`${flow.startedAt}Z`))} + + {flow.url} + {flow.remoteAddr ?? } + {versions} + + {flow.elapsedMs == null ? ( + + ) : flow.elapsedHeadersMs == null ? ( + `${formatMillis(flow.elapsedMs)} in all` + ) : ( + `${formatMillis(flow.elapsedMs)} in all, headers after ${formatMillis(flow.elapsedHeadersMs)}` + )} + + + + {formatSize(flow.requestSize)} sent, {formatSize(flow.responseSize)} received{" "} + + ({bytesFormat.format(flow.responseSize)} B) + + + + + + + +
+ ); +} + +export function FlowQuery({ flow }: { flow: Flow }) { + const params = useMemo(() => queryParams(flow.url), [flow.url]); + + if (params.length === 0) { + return No query parameters; + } + + return ( +
+ + {params.map(([name, value], i) => ( + // oxlint-disable-next-line react/no-array-index-key -- a name can repeat + + {value} + + ))} + +
+ ); +} + +export function queryParams(url: string): [string, string][] { + try { + return [...new URL(url).searchParams.entries()]; + } catch { + return []; + } +} + +function Missing() { + return --; +} diff --git a/apps/yaak-proxy/components/FlowList.tsx b/apps/yaak-proxy/components/FlowList.tsx new file mode 100644 index 000000000..9308e7e6d --- /dev/null +++ b/apps/yaak-proxy/components/FlowList.tsx @@ -0,0 +1,412 @@ +import { useVirtualizer } from "@tanstack/react-virtual"; +import { formatSize } from "@yaakapp-internal/lib/formatSize"; +import type { Flow } from "@yaakapp-internal/proxy-lib"; +import { + ContextMenu, + formatMillis, + HttpMethodTagRaw, + HttpStatusTagRaw, + Icon, +} from "@yaakapp-internal/ui"; +import classNames from "classnames"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import type { CSSProperties, KeyboardEvent, MouseEvent, WheelEvent } from "react"; +import { memo, useCallback, useLayoutEffect, useRef, useState } from "react"; +import { selectedFlowAtom, selectedFlowIdAtom } from "../lib/atoms"; +import { shortContentType } from "../lib/body"; +import { useFlowActions } from "../lib/flowActions"; +import { compareFlows } from "../lib/flowWindow"; +import { countNewFlows } from "../lib/follow"; +import { + activeQueryAtom, + followAtom, + hasAppsAtom, + loadOlderFlowsAtom, + newestStartedAtAtom, + oldestFlowAtom, + searchWindowAtom, + shownFlowsAtom, +} from "../lib/store"; +import { AppIcon } from "./AppIcon"; +import { SearchBar } from "./SearchBar"; + +const ROW_HEIGHT = 24; +const BOTTOM_PADDING = 6; +/** Rows from the top at which older flows start loading */ +const LOAD_OLDER_ROWS = 20; + +interface Props { + sideBySide: boolean; + style?: CSSProperties; + className?: string; +} + +/** + * Follows new traffic until a flow is selected or the list is scrolled, then holds. Scrolling to + * the top loads older flows from the database. + */ +export function FlowList({ sideBySide, style, className }: Props) { + const flows = useAtomValue(shownFlowsAtom); + const hasApps = useAtomValue(hasAppsAtom); + const [menu, setMenu] = useState<{ flow: Flow; x: number; y: number } | null>(null); + const flowActions = useFlowActions(); + const selectedId = useAtomValue(selectedFlowIdAtom); + const setSelectedFlow = useSetAtom(selectedFlowAtom); + const loadOlder = useSetAtom(loadOlderFlowsAtom); + const oldestFlow = useAtomValue(oldestFlowAtom); + const scrollRef = useRef(null); + const ignoreScroll = useRef(false); + const [following, setFollowing] = useAtom(followAtom); + const [heldAt, setHeldAt] = useState(null); + // The newest flow across all traffic, not just this list, so a filter change keeps the hold + const newestStartedAt = useAtomValue(newestStartedAtAtom); + // Read by the callbacks below, which stay the same across new traffic so rows don't re-render + const latest = useRef({ flows, following, newestStartedAt }); + useLayoutEffect(() => { + latest.current = { flows, following, newestStartedAt }; + }); + + const virtualizer = useVirtualizer({ + count: flows.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ROW_HEIGHT, + getItemKey: (index) => flows[index]?.id ?? index, + paddingEnd: BOTTOM_PADDING, + overscan: 40, + }); + + const setScrollTop = useCallback((top: number) => { + const el = scrollRef.current; + if (el == null) return; + const max = el.scrollHeight - el.clientHeight; + const next = Math.max(0, Math.min(top, max)); + if (Math.abs(el.scrollTop - next) < 1) return; + ignoreScroll.current = true; + el.scrollTop = next; + }, []); + + const hold = useCallback(() => { + if (!latest.current.following) return; + setFollowing(false); + setHeldAt(latest.current.newestStartedAt); + }, [setFollowing]); + + const follow = useCallback(() => { + setFollowing(true); + setHeldAt(null); + setScrollTop(Number.MAX_SAFE_INTEGER); + }, [setFollowing, setScrollTop]); + + useLayoutEffect(() => { + if (following) setScrollTop(Number.MAX_SAFE_INTEGER); + }, [following, flows, setScrollTop]); + + // Older flows that loaded in above push the rows down; scroll by as much, so they stay put. + const anchor = useRef(null); + useLayoutEffect(() => { + const previous = anchor.current; + anchor.current = oldestFlow; + if (following || previous == null || oldestFlow == null) return; + if (compareFlows(oldestFlow, previous) >= 0) return; + const added = flows.findIndex((f) => compareFlows(f, previous) >= 0); + const el = scrollRef.current; + if (added > 0 && el != null) setScrollTop(el.scrollTop + added * ROW_HEIGHT); + }, [oldestFlow, flows, following, setScrollTop]); + + const loadOlderHeld = useCallback(() => { + hold(); + void loadOlder(); + }, [hold, loadOlder]); + + const handleScroll = useCallback(() => { + if (ignoreScroll.current) { + ignoreScroll.current = false; + return; + } + const el = scrollRef.current; + if (el == null) return; + const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 2; + if (atEnd) { + setFollowing(true); + setHeldAt(null); + } else { + hold(); + if (el.scrollTop < LOAD_OLDER_ROWS * ROW_HEIGHT) void loadOlder(); + } + }, [hold, loadOlder, setFollowing]); + + // A list too short to scroll still loads older flows on scrolling up. + const handleWheel = useCallback( + (e: WheelEvent) => { + if (e.deltaY < 0 && e.currentTarget.scrollTop <= 0) loadOlderHeld(); + }, + [loadOlderHeld], + ); + + const select = useCallback( + (index: number) => { + const flow = latest.current.flows[index]; + if (flow == null) return; + setSelectedFlow(flow); + hold(); + const el = scrollRef.current; + if (el == null) return; + const top = index * ROW_HEIGHT; + if (top < el.scrollTop) setScrollTop(top); + else if (top + ROW_HEIGHT > el.scrollTop + el.clientHeight) { + setScrollTop(top + ROW_HEIGHT - el.clientHeight); + } + }, + [hold, setSelectedFlow, setScrollTop], + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; + e.preventDefault(); + const index = flows.findIndex((f) => f.id === selectedId); + if (index === -1) select(e.key === "ArrowDown" ? 0 : flows.length - 1); + else if (index === 0 && e.key === "ArrowUp") loadOlderHeld(); + else select(e.key === "ArrowDown" ? index + 1 : index - 1); + }, + [flows, loadOlderHeld, select, selectedId], + ); + + const openMenu = useCallback( + (index: number, e: MouseEvent) => { + const flow = latest.current.flows[index]; + if (flow == null) return; + e.preventDefault(); + select(index); + setMenu({ flow, x: e.clientX, y: e.clientY }); + }, + [select], + ); + + const newCount = following ? 0 : countNewFlows(flows, heldAt); + + const columns = sideBySide + ? hasApps + ? "grid-cols-[0.9rem_1rem_2.6rem_minmax(0,2fr)_minmax(0,3fr)_2.4rem_3.6rem]" + : "grid-cols-[0.9rem_2.6rem_minmax(0,2fr)_minmax(0,3fr)_2.4rem_3.6rem]" + : hasApps + ? "grid-cols-[0.9rem_8.5rem_2.6rem_13rem_minmax(0,1fr)_2.4rem_5rem_4.5rem_3.8rem]" + : "grid-cols-[0.9rem_2.6rem_13rem_minmax(0,1fr)_2.4rem_5rem_4.5rem_3.8rem]"; + + return ( +
+ +
+ + {hasApps && {sideBySide ? "" : "App"}} + Method + Host + Path + Status + {!sideBySide && Type} + {!sideBySide && Size} + Time +
+
+
+ {flows.length === 0 ? ( + + ) : ( +
+ {virtualizer.getVirtualItems().map((item) => { + const flow = flows[item.index]; + if (flow == null) return null; + return ( + + ); + })} +
+ )} +
+ {newCount > 0 && ( + + )} + setMenu(null)} + /> +
+
+ ); +} + +/** Nothing to list, or nothing a search matches */ +function EmptyList() { + const query = useAtomValue(activeQueryAtom); + const search = useAtomValue(searchWindowAtom); + if (search?.loading) return null; + return ( +

+ {query == null ? "No traffic yet" : "No flows match"} +

+ ); +} + +interface RowProps { + flow: Flow; + index: number; + start: number; + columns: string; + sideBySide: boolean; + showApp: boolean; + selected: boolean; + onSelect: (index: number) => void; + onContextMenu: (index: number, e: MouseEvent) => void; +} + +const FlowRow = memo(function FlowRow({ + flow, + index, + start, + columns, + sideBySide, + showApp, + selected, + onSelect, + onContextMenu, +}: RowProps) { + const { host, path, query } = splitUrl(flow.url); + const failed = flow.error != null; + // Failed or cancelled before any response; after the headers, the body was cut short mid-way. + const noResponse = (failed || flow.cancelled) && flow.status == null; + const type = shortContentType(flow.contentType); + + return ( +
{ + if (e.button === 0) onSelect(index); + }} + onContextMenu={(e) => onContextMenu(index, e)} + style={{ height: ROW_HEIGHT, transform: `translateY(${start}px)` }} + className={classNames( + columns, + "absolute top-0 inset-x-0 grid items-center gap-x-3 px-1.5 rounded-md whitespace-nowrap", + "[&>*]:overflow-hidden [&>*]:text-ellipsis [&>*]:min-w-0", + selected + ? "text-text bg-surface-highlight group-focus-within/flows:bg-surface-active" + : index % 2 === 1 && "bg-surface-highlight/30", + )} + > + + {flow.savedRequestId != null ? ( + + ) : failed ? ( + + ) : flow.cancelled ? ( + + ) : flow.responseTruncated ? ( + + ) : null} + + {showApp && ( + + {flow.clientPath != null && } + {!sideBySide && ( + + {flow.clientName ?? --} + + )} + + )} + + {host} + + {path} + {query} + + {flow.cancelled && flow.status == null ? ( + -- + ) : ( + + )} + {!sideBySide && + (failed ? ( + + {flow.error} + + ) : flow.cancelled ? ( + Cancelled + ) : ( + + {type ?? --} + + ))} + {!sideBySide && ( + + {noResponse ? ( + -- + ) : ( + formatSize(flow.responseSize) + )} + + )} + + {flow.elapsedMs == null ? "--" : formatMillis(flow.elapsedMs)} + +
+ ); +}); + +function splitUrl(url: string): { host: string; path: string; query: string } { + try { + const u = new URL(url); + return { host: u.host, path: u.pathname, query: u.search }; + } catch { + return { host: "", path: url, query: "" }; + } +} diff --git a/apps/yaak-proxy/components/HelperDialog.tsx b/apps/yaak-proxy/components/HelperDialog.tsx new file mode 100644 index 000000000..a440ae97c --- /dev/null +++ b/apps/yaak-proxy/components/HelperDialog.tsx @@ -0,0 +1,96 @@ +import { useQueryClient } from "@tanstack/react-query"; +import type { SystemProxyStatus } from "@yaakapp-internal/proxy-lib"; +import { Banner, Dialog, DialogFooter, LoadingIcon } from "@yaakapp-internal/ui"; +import { useAtom, useSetAtom } from "jotai"; +import { useEffect } from "react"; +import loginItems from "../assets/login-items.png"; +import { useRpcMutation } from "../hooks/useRpcMutation"; +import { useRpcQuery } from "../hooks/useRpcQuery"; +import { helperDialogOpenAtom, helperRegisteredAtAtom } from "../lib/atoms"; + +/** Yaak Proxy's row in `login-items.png`, as percentages of the image, for the highlight */ +const YAAK_PROXY_ROW = { left: 1, top: 34, width: 98, height: 32 }; + +/** + * Walks through allowing the helper: registering it opens System Settings, where the user turns + * it on. Stays open while macOS awaits that approval, and closes itself once it is given. + */ +export function HelperDialog() { + const [open, setOpen] = useAtom(helperDialogOpenAtom); + const queryClient = useQueryClient(); + const setRegisteredAt = useSetAtom(helperRegisteredAtAtom); + // Polled by the header while approval is pending, so this sees it as soon as it is given. + const status = useRpcQuery("system_proxy_status", {}); + const register = useRpcMutation("helper_register", { + onSuccess: (status) => { + setRegisteredAt(Date.now()); + queryClient.setQueryData(["system_proxy_status", {}], status); + }, + }); + const helper = status.data?.helper; + const waiting = helper === "requires_approval"; + const { reset } = register; + + useEffect(() => { + if (open && helper === "enabled") { + setOpen(false); + reset(); + } + }, [open, helper, setOpen, reset]); + + const close = () => { + setOpen(false); + reset(); + }; + + return ( + +
+
+ System Settings, with Yaak Proxy's toggle in the list of apps allowed in the background +
+
+ {register.error != null && {register.error.message}} +
+ + + Waiting for approval + + ) + } + actions={[ + { label: waiting ? "Close" : "Cancel", onClick: close }, + { + label: "Open System Settings", + color: "primary", + isLoading: register.isPending, + onClick: () => register.mutate({}), + }, + ]} + /> +
+ ); +} diff --git a/apps/yaak-proxy/components/ProxyEditorHost.tsx b/apps/yaak-proxy/components/ProxyEditorHost.tsx new file mode 100644 index 000000000..f02423ce4 --- /dev/null +++ b/apps/yaak-proxy/components/ProxyEditorHost.tsx @@ -0,0 +1,35 @@ +import { + type EditorHost, + EditorHostProvider, + MultipartViewer, + type MultipartViewerProps, + TextViewer, + type TextViewerProps, +} from "@yaakapp-internal/ui"; +import { type ReactNode, useMemo } from "react"; +import { formatText as engineFormatText } from "../lib/body"; + +/** + * Gives the shared panes the proxy's formatting. Everything else keeps the shared defaults: + * plain editors with no templating, state kept for the session, no dialogs. + */ +export function ProxyEditorHost({ + children, + formatText = engineFormatText, +}: { + children: ReactNode; + /** Pretty-prints a body; the engine's formatter unless replaced */ + formatText?: TextViewerProps["formatText"]; +}) { + // Components made once per formatter, so viewers aren't remounted on every render + const host = useMemo>( + () => ({ + TextViewer: (props: TextViewerProps) => , + MultipartViewer: (props: MultipartViewerProps) => ( + + ), + }), + [formatText], + ); + return {children}; +} diff --git a/apps/yaak-proxy/components/ProxyLayout.tsx b/apps/yaak-proxy/components/ProxyLayout.tsx index 9a8f5f8d7..46e3017e9 100644 --- a/apps/yaak-proxy/components/ProxyLayout.tsx +++ b/apps/yaak-proxy/components/ProxyLayout.tsx @@ -1,17 +1,32 @@ -import { HeaderSize, IconButton, SidebarLayout, SplitLayout } from "@yaakapp-internal/ui"; +import { + HeaderSize, + IconButton, + SidebarLayout, + SplitLayout, + type SplitLayoutLayout, + Toasts, +} from "@yaakapp-internal/ui"; import classNames from "classnames"; -import { useAtomValue } from "jotai"; +import { useAtom, useAtomValue } from "jotai"; import { useState } from "react"; import { useLocalStorage } from "react-use"; -import { useRpcQueryWithEvent } from "../hooks/useRpcQueryWithEvent"; +import { flowLayoutAtom } from "../lib/atoms"; +import { searchWindowAtom, showFirstRunAtom, shownTotalAtom } from "../lib/store"; import { getOsType } from "../lib/tauri"; -import { ActionIconButton } from "./ActionIconButton"; -import { ExchangesTable } from "./ExchangesTable"; -import { filteredExchangesAtom, Sidebar } from "./Sidebar"; +import { ClearFlowsButton } from "./ClearFlowsButton"; +import { FirstRun } from "./FirstRun"; +import { FlowDetail } from "./FlowDetail"; +import { FlowList } from "./FlowList"; +import { ProxyStatus } from "./ProxyStatus"; +import { HelperDialog } from "./HelperDialog"; +import { SaveDialog } from "./SaveDialog"; +import { selectedSidebarLabelAtom, Sidebar } from "./Sidebar"; +import { TakeOverBanner } from "./TakeOverBanner"; export function ProxyLayout() { const os = getOsType(); - const exchanges = useAtomValue(filteredExchangesAtom); + const title = useAtomValue(selectedSidebarLabelAtom); + const [flowLayout, setFlowLayout] = useAtom(flowLayoutAtom); const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar_width", 250); const [sidebarHidden, setSidebarHidden] = useLocalStorage("sidebar_hidden", false); const [floatingSidebarHidden, setFloatingSidebarHidden] = useLocalStorage( @@ -19,14 +34,12 @@ export function ProxyLayout() { true, ); const [floating, setFloating] = useState(false); - const { data: proxyState } = useRpcQueryWithEvent("get_proxy_state", {}, "proxy_state_changed"); - const isRunning = proxyState?.state === "running"; const isHidden = floating ? (floatingSidebarHidden ?? true) : (sidebarHidden ?? false); return (
@@ -57,29 +70,24 @@ export function ProxyLayout() {
- Yaak Proxy + {title} +
-
- {isRunning ? ( - <> - Running :9090 - - - ) : ( - - )} +
+ + + + setFlowLayout((prev) => (prev === "horizontal" ? "vertical" : "horizontal")) + } + />
@@ -124,23 +132,46 @@ export function ProxyLayout() { ) } > - ( - - )} - secondSlot={({ style }) => ( -
- Select a request to view details -
- )} - /> +
+ +
+ +
+
+ + +
); } + +/** Its own component, so new traffic re-renders the count rather than the whole layout */ +function FlowCount() { + const count = useAtomValue(shownTotalAtom); + const searching = useAtomValue(searchWindowAtom) != null; + const noun = searching ? (count === 1 ? "match" : "matches") : count === 1 ? "flow" : "flows"; + return {`${count} ${noun}`}; +} + +/** + * The flow list above the detail pane, or, while the database has no flows at all, the first-run + * card across the whole area: it is the empty state until traffic replaces it. + */ +function FlowsOrFirstRun({ flowLayout }: { flowLayout: SplitLayoutLayout }) { + if (useAtomValue(showFirstRunAtom)) return ; + return ( + ( + + )} + secondSlot={({ style, orientation }) => ( + + )} + /> + ); +} diff --git a/apps/yaak-proxy/components/ProxyStatus.tsx b/apps/yaak-proxy/components/ProxyStatus.tsx new file mode 100644 index 000000000..48a9779f5 --- /dev/null +++ b/apps/yaak-proxy/components/ProxyStatus.tsx @@ -0,0 +1,480 @@ +import { useQueryClient } from "@tanstack/react-query"; +import type { CaStatus, SystemProxyStatus } from "@yaakapp-internal/proxy-lib"; +import { + Button, + Checkbox, + Dropdown, + type DropdownItem, + type DropdownRef, + Icon, +} from "@yaakapp-internal/ui"; +import classNames from "classnames"; +import { useAtomValue, useSetAtom, useStore } from "jotai"; +import type { HTMLAttributes, ReactNode, Ref } from "react"; +import { useEffect, useRef } from "react"; +import { useRpcEvent } from "../hooks/useRpcEvent"; +import { useRpcMutation } from "../hooks/useRpcMutation"; +import { useRpcQuery } from "../hooks/useRpcQuery"; +import { helperDialogOpenAtom, helperRegisteredAtAtom } from "../lib/atoms"; +import { environmentExports } from "../lib/env"; +import { proxyStateAtom, refreshProxyState } from "../lib/store"; +import { + helperPollInterval, + helperRow, + recordingLabel, + systemProxySwitch, +} from "../lib/systemProxy"; +import { onWindowFocus } from "../lib/tauri"; +import { useShowToast } from "../lib/toast"; + +/** + * The header's state pill and its menu: recording, the system proxy, the certificate and HTTP/2. The + * engine runs for as long as the app does, so the pill shows whether flows are being stored. A + * certificate that is not trusted steps out of the menu beside the pill until it is. + */ +export function ProxyStatus() { + const dropdownRef = useRef(null); + const proxyState = useAtomValue(proxyStateAtom); + const setup = useProxySetup(); + const execute = useRpcMutation("execute_action"); + const retry = useRpcMutation("take_over"); + const setAtLaunch = useSetSystemProxyAtLaunch(); + const setHttpVersion = useSetHttpVersion(); + const copyEnvironment = useCopyEnvironment(); + const setHelperDialogOpen = useSetAtom(helperDialogOpenAtom); + const { caStatus, trust, systemProxy, setSystemProxy } = setup; + const systemSwitch = useSystemProxySwitch(setup); + + const running = proxyState?.state === "running"; + const recording = proxyState?.recording !== false; + const other = proxyState?.otherOwner ?? null; + const port = proxyState?.port ?? other?.proxyPort ?? null; + const untrusted = caStatus.data != null && !caStatus.data.trusted; + + const stateRow = + other != null ? ( + } + title="Running from the CLI" + description={`Started by yaak proxy start, pid ${other.pid}, since ${timeOf(other.startedAt)}.${other.proxyPort == null ? "" : ` Port ${other.proxyPort}.`}`} + /> + ) : running ? ( + } + title={recording ? `Recording on port ${port}` : `Paused on port ${port}`} + description={ + recording + ? "Listening on 127.0.0.1, and storing every flow." + : "Traffic passes through untouched, and no flows are stored." + } + /> + ) : ( + } + title="Not running" + description={ + + {retry.error?.message ?? proxyState?.startError ?? "The proxy has not started."} + + } + action={ + + } + /> + ); + + const items: DropdownItem[] = [ + { type: "content", label: stateRow }, + { type: "separator", hidden: systemProxy.data?.supported !== true }, + { + type: "content", + hidden: systemProxy.data?.supported !== true, + label: ( + } + title="Override system proxy" + description={ + + {systemSwitch.description} + + } + action={ + + setSystemProxy.mutate({ enabled })} + /> + + } + /> + ), + }, + { + type: "content", + hidden: systemProxy.data?.supported !== true, + label: ( + + Override system proxy at launch + } + checked={proxyState?.systemProxyAtLaunch ?? true} + disabled={setAtLaunch.isPending} + onChange={(enabled) => setAtLaunch.mutate({ enabled })} + /> + + ), + }, + { + type: "content", + hidden: systemProxy.data?.supported !== true, + label: systemProxy.data != null && ( + { + dropdownRef.current?.close?.(); + setHelperDialogOpen(true); + }} + /> + ), + }, + { + type: "content", + label: caStatus.data?.trusted ? ( + } + title="Certificate trusted" + description="Yaak Proxy CA, in your login keychain." + /> + ) : ( + } + title="Certificate not trusted" + description={ + trust.error != null ? ( + {trust.error.message} + ) : trust.isPending ? ( + "Waiting for your password in the macOS prompt." + ) : ( + "So HTTPS reads as plain text. macOS asks for your login password once." + ) + } + action={ + + } + /> + ), + }, + { + type: "content", + label: ( + } + title="HTTP/2" + description="Lets browsers send many requests at once. Turn off for apps that misbehave on it. Applies to new connections." + action={ + + + setHttpVersion.mutate({ httpVersion: enabled ? "auto" : "http1Only" }) + } + /> + + } + /> + ), + }, + { type: "separator" }, + { + label: "Copy Environment for a Terminal", + icon: "square_terminal", + onSelect: copyEnvironment, + }, + { + label: recordingLabel(recording), + icon: recording ? "circle_pause" : "circle_play", + hidden: !running, + hotKeyAction: "recording_toggle", + hotKeyLabelOnly: true, + onSelect: () => execute.mutate({ scope: "global", action: "recording_toggle" }), + }, + ]; + + return ( + <> + {untrusted && ( + dropdownRef.current?.open()}> + + Certificate not trusted + + )} + + + {other != null ? ( + <> + + CLI + {other.proxyPort != null && ( + :{other.proxyPort} + )} + + ) : running ? ( + <> + + {recording ? "Recording" : "Paused"} + {port != null && :{port}} + + ) : ( + <> + + Not running + + )} + + + + + ); +} + +/** + * The certificate and the system proxy, read once and shared by the header and the first-run + * card, with the actions that change them. + */ +export function useProxySetup() { + const queryClient = useQueryClient(); + const caStatus = useRpcQuery("ca_status", {}); + const registeredAt = useAtomValue(helperRegisteredAtAtom); + const systemProxy = useRpcQuery( + "system_proxy_status", + {}, + { + // The helper is approved in System Settings, out of the app's sight. + refetchInterval: (query) => + helperPollInterval(query.state.data?.helper, registeredAt, Date.now()), + }, + ); + // The user comes back from System Settings, maybe having just allowed the helper. + const { refetch: refetchSystemProxy } = systemProxy; + useEffect(() => onWindowFocus(() => void refetchSystemProxy()), [refetchSystemProxy]); + const trust = useRpcMutation("ca_trust", { + mutationKey: ["ca_trust"], + onSuccess: (status) => queryClient.setQueryData(["ca_status", {}], status), + }); + const setSystemProxy = useRpcMutation("system_proxy_set", { + mutationKey: ["system_proxy_set"], + onSuccess: (status) => { + queryClient.setQueryData(["system_proxy_status", {}], status); + }, + }); + // The engine turns the system proxy on as it starts, and says so once the prompt is + // answered, and turns it off as it stops. + useRpcEvent("proxy_state_changed", () => void systemProxy.refetch()); + return { caStatus, trust, systemProxy, setSystemProxy }; +} + +/** The system proxy switch's state, from where it points and the change in flight */ +export function useSystemProxySwitch(setup: ReturnType) { + const proxyState = useAtomValue(proxyStateAtom); + const { systemProxy, setSystemProxy } = setup; + return systemProxySwitch({ + routing: systemProxy.data?.enabled === true, + port: proxyState?.port ?? proxyState?.configuredPort ?? null, + pending: setSystemProxy.isPending ? (setSystemProxy.variables?.enabled ?? null) : null, + error: setSystemProxy.error?.message ?? null, + helper: systemProxy.data?.backend === "helper", + }); +} + +/** The privileged helper: whether it is installed and approved, and which way changes go */ +function HelperMenuRow({ + status, + onOpenDialog, +}: { + status: SystemProxyStatus; + onOpenDialog: () => void; +}) { + const queryClient = useQueryClient(); + const unregister = useRpcMutation("helper_unregister", { + onSuccess: (status) => + queryClient.setQueryData(["system_proxy_status", {}], status), + }); + const row = helperRow(status.helper, status.backend); + const error = unregister.error; + return ( + + } + title={row.title} + description={ + error != null ? {error.message} : row.description + } + action={ + row.action != null && ( + + ) + } + /> + ); +} + +/** Saves whether the system proxy turns on as the app launches */ +function useSetSystemProxyAtLaunch() { + const store = useStore(); + return useRpcMutation("set_system_proxy_at_launch", { + onSettled: () => void refreshProxyState(store), + }); +} + +/** Saves the HTTP versions offered to clients, which the engine applies to new connections */ +function useSetHttpVersion() { + const store = useStore(); + return useRpcMutation("set_http_version", { + onSettled: () => void refreshProxyState(store), + }); +} + +export function useCopyEnvironment() { + const proxyState = useAtomValue(proxyStateAtom); + const caStatus = useRpcQuery("ca_status", {}); + const showToast = useShowToast(); + return async () => { + const port = + proxyState?.port ?? proxyState?.otherOwner?.proxyPort ?? proxyState?.configuredPort; + const caPath = caStatus.data?.certPath; + if (port == null || caPath == null) return; + try { + await navigator.clipboard.writeText(environmentExports(port, caPath)); + showToast({ + message: "Copied the environment. Paste it into a terminal to proxy what runs there.", + }); + } catch (err) { + showToast({ message: `Failed to copy: ${String(err)}`, color: "danger" }); + } + }; +} +/** Keeps a click on a control inside a menu row from closing the menu, as a row's click does */ +function KeepMenuOpen({ children, className }: { children: ReactNode; className?: string }) { + return ( + // oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- only stops the click reaching the row + e.stopPropagation()}> + {children} + + ); +} + +function timeOf(iso: string): string { + return new Date(iso).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); +} + +interface PillProps extends HTMLAttributes { + color?: "warning"; + ref?: Ref; +} + +function Pill({ children, color, ref, ...props }: PillProps) { + return ( + + ); +} + +export function StateDot({ state }: { state: "recording" | "paused" | "cli" }) { + return ( + + ); +} + +function MenuRow({ + icon, + title, + description, + action, +}: { + icon: ReactNode; + title: string; + description: ReactNode; + action?: ReactNode; +}) { + return ( +
+ {icon} +
+
{title}
+
{description}
+
+ {action} +
+ ); +} diff --git a/apps/yaak-proxy/components/SaveDialog.tsx b/apps/yaak-proxy/components/SaveDialog.tsx new file mode 100644 index 000000000..653632474 --- /dev/null +++ b/apps/yaak-proxy/components/SaveDialog.tsx @@ -0,0 +1,320 @@ +import type { + DropReason, + Flow, + FolderSummary, + WorkspaceSummary, +} from "@yaakapp-internal/proxy-lib"; +import { + Banner, + Dialog, + DialogFooter, + HttpMethodTagRaw, + LoadingIcon, + PlainInput, +} from "@yaakapp-internal/ui"; +import { unnamedRequestName } from "@yaakapp-internal/ui/resolvedModelName"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { useAtom } from "jotai"; +import { useEffect, useMemo, useState } from "react"; +import { useLocalStorage } from "react-use"; +import { useRpcMutation } from "../hooks/useRpcMutation"; +import { useRpcQuery } from "../hooks/useRpcQuery"; +import { exportHar, saveDialogFlowAtom } from "../lib/flowActions"; +import { useShowToast } from "../lib/toast"; + +const DOWNLOAD_URL = "https://yaak.app/download"; +const SAVE_FORM_ID = "save-flow-to-yaak"; + +/** Save as Yaak request, for the flow in `saveDialogFlowAtom` */ +export function SaveDialog() { + const [flow, setFlow] = useAtom(saveDialogFlowAtom); + const workspaces = useRpcQuery("list_workspaces", {}, { enabled: flow != null, staleTime: 0 }); + const close = () => setFlow(null); + + if (flow == null) return null; + if (workspaces.data?.yaakFound === false) return ; + return ( + + {workspaces.error != null ? ( + {workspaces.error.message} + ) : workspaces.data == null ? ( + + ) : ( + + )} + + ); +} + +function SaveForm({ + flow, + workspaces, + onClose, +}: { + flow: Flow; + workspaces: WorkspaceSummary[]; + onClose: () => void; +}) { + const showToast = useShowToast(); + const preview = useRpcQuery("preview_save", { flowId: flow.id }, { staleTime: 0 }); + const [lastWorkspaceId, setLastWorkspaceId] = useLocalStorage("save_workspace_id"); + const [workspaceId, setWorkspaceId] = useState( + workspaces.find((w) => w.id === lastWorkspaceId)?.id ?? workspaces[0]?.id ?? "", + ); + const [folderId, setFolderId] = useState(""); + const [name, setName] = useState(""); + const save = useRpcMutation("save_flow"); + + const workspace = workspaces.find((w) => w.id === workspaceId); + const folders = useMemo(() => folderOptions(workspace?.folders ?? []), [workspace]); + useEffect(() => setFolderId(""), [workspaceId]); + + const p = preview.data; + // Unnamed unless one is typed, as Yaak leaves a new request, which then shows its URL. + const unnamed = + p == null + ? "" + : unnamedRequestName({ model: "http_request", url: p.requestUrl, bodyType: p.bodyType }); + const folderName = folders.find((f) => f.id === folderId)?.name; + + const submit = () => { + if (workspace == null) return; + save.mutate( + { + flowId: flow.id, + workspaceId: workspace.id, + ...(folderId === "" ? {} : { folderId }), + ...(name.trim() === "" ? {} : { name }), + }, + { + onSuccess: (saved) => { + setLastWorkspaceId(workspace.id); + onClose(); + showToast({ + message: ( + <> + Saved {saved.name || unnamed} to {workspace.name} + {folderName == null ? "." : `, in ${folderName}.`} Yaak picks it up within a second. + + ), + }); + }, + }, + ); + }; + + if (workspaces.length === 0) { + return ( + + Yaak has no workspaces yet. Create one in Yaak, then save here. + + ); + } + + if (p == null) { + return preview.error != null ? ( + {preview.error.message} + ) : ( + + ); + } + + return ( +
{ + e.preventDefault(); + submit(); + }} + > +
+ + {flow.url} +
+
+ + + + + + +
+ + {p.droppedHeaders.length > 0 && ( +
+
+ {p.droppedHeaders.length === 1 + ? "1 header left out" + : `${p.droppedHeaders.length} headers left out`}{" "} + + {droppedSummary(p.droppedHeaders.map((h) => h.reason))} + +
+
+ {p.droppedHeaders.map((h) => ( + + {h.name} + + ))} +
+
+ )} +
+ Kept + + {p.keptHeaders === 1 ? "1 header" : `${p.keptHeaders} headers`} + {p.body == null ? ", and no body" : `, and the body as ${p.body}`} + + {p.warnings.map((w) => ( + + ))} +
+ {save.error != null && {save.error.message}} + Yaak picks it up within a second + } + actions={[ + { label: "Cancel", onClick: onClose }, + { + label: "Save request", + form: SAVE_FORM_ID, + color: "primary", + isLoading: save.isPending, + disabled: workspace == null, + }, + ]} + /> + + ); +} + +function NoteRow({ warning, authorization }: { warning: string; authorization: boolean }) { + const flagged = authorization && warning.includes("Authorization"); + return ( + <> + Note + {warning} + + ); +} + +/** Without Yaak there is nothing to save into: say so, and offer HAR as a way to keep the flow */ +function NoYaakDialog({ flow, onClose }: { flow: Flow; onClose: () => void }) { + const showToast = useShowToast(); + return ( + + Or keep the flow as a file
} + actions={[ + { + label: "Export HAR", + onClick: async () => { + try { + if ((await exportHar({ ids: [flow.id] }, "flow.har")) != null) { + onClose(); + showToast({ message: "Exported the flow as HAR" }); + } + } catch (err) { + showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" }); + } + }, + }, + { label: "Download Yaak", color: "primary", onClick: () => void openUrl(DOWNLOAD_URL) }, + ]} + /> +
+
Saving needs Yaak
+
+ This flow becomes a request in one of Yaak's workspaces, and Yaak has not been opened on + this Mac. Yaak is free for personal use and opens the request with the same viewers you + see here. +
+
+ + ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + // oxlint-disable-next-line jsx-a11y/label-has-associated-control -- wraps its control + + ); +} + +const inputClasses = + "h-sm w-full min-w-0 px-2 rounded-md border border-border bg-surface text-text text-sm outline-hidden focus:border-border-focus"; + +/** Folders by their path within the workspace, "Stripe / Payments", in path order */ +function folderOptions(folders: FolderSummary[]): { id: string; name: string }[] { + const byId = new Map(folders.map((f) => [f.id, f])); + const pathOf = (f: FolderSummary): string => { + const names = [f.name]; + const seen = new Set([f.id]); + let parent = f.folderId == null ? undefined : byId.get(f.folderId); + while (parent != null && !seen.has(parent.id)) { + names.unshift(parent.name); + seen.add(parent.id); + parent = parent.folderId == null ? undefined : byId.get(parent.folderId); + } + return names.join(" / "); + }; + return folders + .map((f) => ({ id: f.id, name: pathOf(f) })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +function reasonLabel(reason: DropReason): string { + switch (reason) { + case "set_by_sending": + return "Yaak sets it when it sends"; + case "hop_by_hop": + return "Only meant for the proxy"; + case "body_decoded": + return "The body is saved decoded"; + } +} + +function droppedSummary(reasons: DropReason[]): string { + const unique = [...new Set(reasons)]; + if (unique.length === 1 && unique[0] === "set_by_sending") { + return "Yaak sets these itself when it sends"; + } + return "Yaak sets these itself, or they only meant something to the proxy"; +} diff --git a/apps/yaak-proxy/components/SearchBar.tsx b/apps/yaak-proxy/components/SearchBar.tsx new file mode 100644 index 000000000..ed9d107f3 --- /dev/null +++ b/apps/yaak-proxy/components/SearchBar.tsx @@ -0,0 +1,111 @@ +import { Icon, IconButton, PlainInput } from "@yaakapp-internal/ui"; +import { useAtomValue, useSetAtom } from "jotai"; +import { memo, useEffect, useRef, useState } from "react"; +import { exportHar } from "../lib/flowActions"; +import { searchTerms } from "../lib/search"; +import { activeQueryAtom, searchTermsAtom } from "../lib/store"; +import { useShowToast } from "../lib/toast"; + +/** How long typing settles before the search runs */ +const SEARCH_DEBOUNCE_MS = 200; + +/** + * Searches every flow in the database by URL, method, status and content type, within the + * sidebar's selection. ⌘F focuses it and Escape clears it. + */ +export const SearchBar = memo(function SearchBar() { + const [text, setText] = useState(""); + const setTerms = useSetAtom(searchTermsAtom); + const query = useAtomValue(activeQueryAtom); + const showToast = useShowToast(); + // PlainInput hands back its as the ref + const inputRef = useRef(null); + // Bumped to empty the field, which only takes a value from outside when this changes + const [resetKey, setResetKey] = useState(0); + + useEffect(() => { + const timer = setTimeout(() => setTerms(searchTerms(text)), SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [text, setTerms]); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "f") { + e.preventDefault(); + inputRef.current?.focus(); + inputRef.current?.select(); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + // The field is replaced to empty it, so focus moves to the new one. + const clear = () => { + setText(""); + setTerms(null); + setResetKey((k) => k + 1); + requestAnimationFrame(() => inputRef.current?.focus()); + }; + + return ( +
+
+ { + if (e.key === "Escape" && text !== "") { + e.stopPropagation(); + clear(); + } + }} + leftSlot={ + + } + rightSlot={ + text === "" ? ( + + ⌘F + + ) : ( + + ) + } + /> +
+ { + try { + const written = await exportHar(query == null ? {} : { filter: query }, "flows.har"); + if (written != null) { + showToast({ + message: + written === 1 ? "Exported 1 flow as HAR" : `Exported ${written} flows as HAR`, + }); + } + } catch (err) { + showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" }); + } + }} + /> +
+ ); +}); diff --git a/apps/yaak-proxy/components/Sidebar.tsx b/apps/yaak-proxy/components/Sidebar.tsx index 74c19cfc3..91c3ff932 100644 --- a/apps/yaak-proxy/components/Sidebar.tsx +++ b/apps/yaak-proxy/components/Sidebar.tsx @@ -1,62 +1,88 @@ -import type { HttpExchange } from "@yaakapp-internal/proxy-lib"; +import type { Flow } from "@yaakapp-internal/proxy-lib"; import type { TreeNode } from "@yaakapp-internal/ui"; import { selectedIdsFamily, Tree } from "@yaakapp-internal/ui"; import { atom, useAtomValue } from "jotai"; -import { atomFamily } from "jotai-family"; +import { atomWithStorage } from "jotai/utils"; import { useCallback } from "react"; -import { httpExchangesAtom } from "../lib/store"; +import { SIDEBAR_TREE_ID } from "../lib/atoms"; +import { appKeyOf, appNodeId, sidebarPath } from "../lib/search"; +import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse"; +import { flowsAtom, flowTotalAtom } from "../lib/store"; +import { AppIcon } from "./AppIcon"; -/** A node in the sidebar tree — either a domain or a path segment. */ +/** A node in the sidebar tree: a domain, a path segment, or an app. */ export type SidebarItem = { id: string; label: string; - exchangeIds: string[]; + /** Flows held under this node; for the Domains folder, every flow in the database */ + count: number; + /** An app's path, for its icon */ + appPath?: string; }; -const collapsedAtom = atomFamily((_treeId: string) => atom>({})); - -export const SIDEBAR_TREE_ID = "proxy-sidebar"; - const sidebarTreeAtom = atom>((get) => { - const exchanges = get(httpExchangesAtom); - return buildTree(exchanges); + const exchanges = get(flowsAtom); + return buildTree(exchanges, get(flowTotalAtom)); }); -/** Exchanges filtered by the currently selected sidebar node(s). */ -export const filteredExchangesAtom = atom((get) => { - const exchanges = get(httpExchangesAtom); - const tree = get(sidebarTreeAtom); - const selectedIds = get(selectedIdsFamily(SIDEBAR_TREE_ID)); - - // Nothing selected or root selected → show all - if (selectedIds.length === 0 || selectedIds.includes("root")) { - return exchanges; - } - - // Collect exchange IDs from all selected nodes - const allowedIds = new Set(); - const nodeMap = new Map(); - collectNodes(tree, nodeMap); - - for (const selectedId of selectedIds) { - const node = nodeMap.get(selectedId); - if (node) { - for (const id of node.exchangeIds) { - allowedIds.add(id); - } - } - } - - return exchanges.filter((ex) => allowedIds.has(ex.id)); +/** The domains and paths the user expanded, by node id, kept across launches */ +const expandedAtom = atomWithStorage>("sidebar_expanded", {}, undefined, { + getOnInit: true, }); -function collectNodes(node: TreeNode, map: Map) { - map.set(node.item.id, node.item); - for (const child of node.children ?? []) { - collectNodes(child, map); +/** The other nodes' collapsed flags, for this launch, as before */ +const otherCollapsedAtom = atom({}); + +/** Domains and paths collapsed until the user expands them, for the tree */ +const collapsedAtom = atom( + (get) => + collapsedMap(folderIds(get(sidebarTreeAtom)), get(expandedAtom), get(otherCollapsedAtom)), + (get, set, action: CollapsedMap | ((prev: CollapsedMap) => CollapsedMap)) => { + const prev = get(collapsedAtom); + const next = typeof action === "function" ? action(prev) : action; + const split = applyCollapsed(prev, next, get(expandedAtom), get(otherCollapsedAtom)); + set(expandedAtom, split.expanded); + set(otherCollapsedAtom, split.others); + }, +); + +/** The selected sidebar node's name, for the header */ +export const selectedSidebarLabelAtom = atom((get) => { + const selectedId = get(selectedIdsFamily(SIDEBAR_TREE_ID))[0]; + if (selectedId?.startsWith("domain:")) return selectedId.slice("domain:".length); + if (selectedId?.startsWith("path:")) return selectedId.slice("path:".length); + if (selectedId?.startsWith("app:")) { + const apps = get(sidebarTreeAtom).children?.find((c) => c.item.id === "apps"); + return apps?.children?.find((c) => c.item.id === selectedId)?.item.label ?? "All traffic"; } + return "All traffic"; +}); + +const nodeIdsByFlow = new WeakMap(); + +/** + * The ids of the nodes a flow sits under, its domain first and then each path segment's, parsed + * once per flow rather than on every change to the list. + */ +function nodeIdsOf(ex: Flow): string[] { + let ids = nodeIdsByFlow.get(ex); + if (ids != null) return ids; + // By the rules the database filters by, so a node shows the flows it counts. + const path = sidebarPath(ex.url); + const schemeless = !ex.url.includes("://"); + const [hostname = "", ...segments] = schemeless ? [path] : path.split("/"); + ids = [`domain:${hostname}`]; + let node = `path:${hostname}`; + for (const seg of segments) { + node += `/${seg}`; + ids.push(node); + } + nodeIdsByFlow.set(ex, ids); + return ids; } +const collator = new Intl.Collator(); + /** * Build a domain → path-segment trie from a flat list of exchanges. * @@ -72,8 +98,8 @@ function collectNodes(node: TreeNode, map: Map * /123 * /orders */ -function buildTree(exchanges: HttpExchange[]): TreeNode { - const root: SidebarItem = { id: "root", label: "All Traffic", exchangeIds: [] }; +function buildTree(exchanges: Flow[], total: number): TreeNode { + const root: SidebarItem = { id: "root", label: "All Traffic", count: 0 }; const rootNode: TreeNode = { item: root, parent: null, @@ -86,54 +112,26 @@ function buildTree(exchanges: HttpExchange[]): TreeNode { type TrieNode = { id: string; label: string; - exchangeIds: string[]; + count: number; children: Map; }; const domainMap = new Map(); for (const ex of exchanges) { - let hostname: string; - let segments: string[]; - try { - const url = new URL(ex.url); - hostname = url.host; - segments = url.pathname.split("/").filter(Boolean); - } catch { - hostname = ex.url; - segments = []; - } - - // Get or create domain node - let domainNode = domainMap.get(hostname); - if (!domainNode) { - domainNode = { - id: `domain:${hostname}`, - label: hostname, - exchangeIds: [], - children: new Map(), - }; - domainMap.set(hostname, domainNode); - } - domainNode.exchangeIds.push(ex.id); - - // Walk path segments - let current = domainNode; - const pathSoFar: string[] = []; - for (const seg of segments) { - pathSoFar.push(seg); - let child = current.children.get(seg); - if (!child) { - child = { - id: `path:${hostname}/${pathSoFar.join("/")}`, - label: `/${seg}`, - exchangeIds: [], - children: new Map(), - }; - current.children.set(seg, child); + const ids = nodeIdsOf(ex); + let current: TrieNode | undefined; + for (const id of ids) { + const siblings = current == null ? domainMap : current.children; + let node = siblings.get(id); + if (node == null) { + // The domain, or the path's last segment + const label = current == null ? id.slice("domain:".length) : id.slice(id.lastIndexOf("/")); + node = { id, label, count: 0, children: new Map() }; + siblings.set(id, node); } - child.exchangeIds.push(ex.id); - current = child; + node.count++; + current = node; } } @@ -147,7 +145,7 @@ function buildTree(exchanges: HttpExchange[]): TreeNode { item: { id: trie.id, label: trie.label, - exchangeIds: trie.exchangeIds, + count: trie.count, }, parent, depth, @@ -155,7 +153,7 @@ function buildTree(exchanges: HttpExchange[]): TreeNode { draggable: false, }; const sortedChildren = [...trie.children.values()].sort((a, b) => - a.label.localeCompare(b.label), + collator.compare(a.label, b.label), ); for (const child of sortedChildren) { node.children?.push(toTreeNode(child, node, depth + 1)); @@ -164,29 +162,57 @@ function buildTree(exchanges: HttpExchange[]): TreeNode { } // Add a "Domains" folder between root and domain nodes - const allExchangeIds = exchanges.map((ex) => ex.id); const domainsFolder: TreeNode = { - item: { id: "domains", label: "Domains", exchangeIds: allExchangeIds }, + item: { id: "domains", label: "Domains", count: total }, parent: rootNode, depth: 1, children: [], draggable: false, }; - const sortedDomains = [...domainMap.values()].sort((a, b) => a.label.localeCompare(b.label)); + const sortedDomains = [...domainMap.values()].sort((a, b) => collator.compare(a.label, b.label)); for (const domain of sortedDomains) { domainsFolder.children?.push(toTreeNode(domain, domainsFolder, 2)); } + // The apps flows came from, where the platform can tell; none on others, so no group. + const apps = new Map(); + for (const ex of exchanges) { + const key = appKeyOf(ex); + if (key == null) continue; + const id = appNodeId(key); + const app = apps.get(id); + if (app == null) { + apps.set(id, { id, label: ex.clientName ?? key, count: 1, appPath: ex.clientPath ?? key }); + } else { + app.count++; + } + } + if (apps.size > 0) { + const appsFolder: TreeNode = { + item: { id: "apps", label: "Apps", count: 0 }, + parent: rootNode, + depth: 1, + children: [], + draggable: false, + }; + const sortedApps = [...apps.values()].sort((a, b) => collator.compare(a.label, b.label)); + for (const item of sortedApps) { + appsFolder.children?.push({ item, parent: appsFolder, depth: 2, draggable: false }); + } + rootNode.children?.push(appsFolder); + } + rootNode.children?.push(domainsFolder); return rootNode; } function ItemInner({ item }: { item: SidebarItem }) { - const count = item.exchangeIds.length; + const count = item.count; return (
+ {item.appPath != null && } {item.label} {count > 0 && {count}}
@@ -195,25 +221,22 @@ function ItemInner({ item }: { item: SidebarItem }) { export function Sidebar() { const tree = useAtomValue(sidebarTreeAtom); + + const getItemKey = useCallback((item: SidebarItem) => `${item.id}:${item.count}`, []); const treeId = SIDEBAR_TREE_ID; - const getItemKey = useCallback( - (item: SidebarItem) => `${item.id}:${item.exchangeIds.length}`, - [], - ); - return ( -
+ ); +} + +function loaded(data: FlowBodyData): HttpBodyResult { + return { data, isLoading: false, error: null }; +} + +/** The engine formats JSON over RPC, which a page outside the app can't reach */ +async function formatLocally(text: string, language: unknown): Promise { + if (language !== "json") return text; + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + return text; + } +} diff --git a/apps/yaak-proxy/harness/fixtures.ts b/apps/yaak-proxy/harness/fixtures.ts new file mode 100644 index 000000000..bc6966b45 --- /dev/null +++ b/apps/yaak-proxy/harness/fixtures.ts @@ -0,0 +1,141 @@ +import type { Flow, GetFlowBodyResponse } from "@yaakapp-internal/proxy-lib"; + +/** A flow fixture with its two bodies, as the engine's `get_flow_body` RPC returns them */ +export interface FlowFixture { + label: string; + flow: Flow; + requestBody: GetFlowBodyResponse; + responseBody: GetFlowBodyResponse; +} + +function body(data: string | Uint8Array): GetFlowBodyResponse { + const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data; + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return { data: btoa(binary), truncated: false, storedSize: bytes.length }; +} + +const base: Flow = { + id: "fl_harness", + createdAt: "2026-11-04T22:10:00", + updatedAt: "2026-11-04T22:10:01", + startedAt: "2026-11-04T22:10:00.000", + method: "GET", + url: "https://api.example.com/", + clientHttpVersion: "HTTP/2", + upstreamHttpVersion: "HTTP/2", + remoteAddr: "93.184.216.34:443", + status: 200, + contentType: null, + requestHeaders: [], + responseHeaders: [], + requestSize: 0, + responseSize: 0, + requestTruncated: false, + responseTruncated: false, + elapsedHeadersMs: 84, + elapsedMs: 112, + error: null, + cancelled: false, + savedRequestId: null, + clientPid: 4211, + clientName: "curl", + clientBundleId: null, + clientPath: "/usr/bin/curl", +}; + +const jsonRequest = JSON.stringify({ amount: 4200, currency: "usd", capture: true }); +const jsonResponse = JSON.stringify({ + id: "pi_3Q8x", + object: "payment_intent", + amount: 4200, + currency: "usd", + status: "succeeded", + charges: { data: [{ id: "ch_1", paid: true }] }, +}); +const formRequest = "grant_type=client_credentials&client_id=yaak-proxy&scope=read+write"; +const htmlResponse = + "

Signed in

You can close this window.

"; +// A 24x24 PNG gradient +const png = Uint8Array.from( + atob( + "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAIAAABvFaqvAAADnUlEQVR4nA3MEQJEIQBAwc95nOdxHj2J8zjP4zyP8zjP47wD7M4B5vv4BJ/kU3yaz/BZPsfn+QJf5Et8ma/wVb7G1/kG3+RbfJvv8F2+x/d9CIGQCIXQCIOwCIfwiICIiITIiIKoiIboiIGYiIXYiIO4iIf4R1IgJVIhNdIgLdIhPTIgIzIhM7IgK7IhO3IgJ3IhN/IgL/Ih/5ESKIlSKI0yKItyKI8KqIhKqIwqqIpqqI4aqIlaqI06qIt6qH+kBVqiFVqjDdqiHdqjAzqiEzqjC7qiG7qjB3qiF3qjD/qiH/ofGYGRGIXRGIOxGIfxmICJmITJmIKpmIbpmIGZmIXZmIO5mIf5R1ZgJVZhNdZgLdZhPTZgIzZhM7ZgK7ZhO3ZgJ3ZhN/ZgL/Zh/5ETOIlTOI0zOItzOI8LuIhLuIwruIpruI4buIlbuI07uIt7uH/kBV7iFV7jDd7iHd7jAz7iEz7jC77iG77jB37iF37jD/7iH/4fBUGQBEXQBEOwBEfwhECIhETIhEKohEbohEGYhEXYhEO4hEf4R1EQJVERNdEQLdERPTEQIzERM7EQK7ERO3EQJ3ERN/EQL/ER/1ESJElSJE0yJEtyJE8KpEhKpEwqpEpqpE4apElapE06pEt6pH+UBVmSFVmTDdmSHdmTAzmSEzmTC7mSG7mTB3mSF3mTD/mSH/kfFUGRFEXRFEOxFEfxlECJlETJlEKplEbplEGZlEXZlEO5lEf5R1VQJVVRNdVQLdVRPTVQIzVRM7VQK7VRO3VQJ3VRN/VQL/VR/1ETNElTNE0zNEtzNE8LtEhLtEwrtEprtE4btElbtE07tEt7tH/UBV3SFV3TDd3SHd3TAz3SEz3TC73SG73TB33SF33TD/3SH/0fDcGQDMXQDMOwDMfwjMCIjMTIjMKojMbojMGYjMXYjMO4jMf4R1MwJVMxNdMwLdMxPTMwIzMxM7MwK7MxO3MwJ3MxN/MwL/Mx/9ESLMlSLM0yLMtyLM8KrMhKrMwqrMpqrM4arMlarM06rMt6rH+0BVuyFVuzDduyHduzAzuyEzuzC7uyG7uzB3uyF3uzD/uyH/sfHcGRHMXRHMOxHMfxnMCJnMTJnMKpnMbpnMGZnMXZnMO5nMf5R1dwJVdxNddwLddxPTdwIzdxM7dwK7dxO3dwJ3dxN/dwL/dx/9ETPMlTPM0zPMtzPM8LvMhLvMwrvMprvM4bvMlbvM07vMt7vB8/gdf/dWsIbgAAAABJRU5ErkJggg==", + ), + (c) => c.charCodeAt(0), +); + +export const fixtures: FlowFixture[] = [ + { + label: "JSON POST", + flow: { + ...base, + id: "fl_json", + method: "POST", + url: "https://api.stripe.com/v1/payment_intents?expand=charges&limit=1", + contentType: "application/json", + requestHeaders: [ + { name: "Content-Type", value: "application/json" }, + { name: "Authorization", value: "Bearer sk_test_51Hx" }, + { name: "Cookie", value: "session=abc123; theme=dark" }, + { name: "User-Agent", value: "curl/8.7.1" }, + ], + responseHeaders: [ + { name: "Content-Type", value: "application/json; charset=utf-8" }, + { name: "Set-Cookie", value: "session=def456; Path=/; HttpOnly; Secure" }, + { name: "Request-Id", value: "req_9Z" }, + ], + requestSize: jsonRequest.length, + responseSize: jsonResponse.length, + }, + requestBody: body(jsonRequest), + responseBody: body(jsonResponse), + }, + { + label: "Form POST, HTML response", + flow: { + ...base, + id: "fl_form", + method: "POST", + url: "https://auth.example.com/oauth/token", + contentType: "text/html", + requestHeaders: [ + { name: "Content-Type", value: "application/x-www-form-urlencoded" }, + { name: "Authorization", value: "Basic eWFhazpwcm94eQ==" }, + ], + responseHeaders: [{ name: "Content-Type", value: "text/html; charset=utf-8" }], + requestSize: formRequest.length, + responseSize: htmlResponse.length, + }, + requestBody: body(formRequest), + responseBody: body(htmlResponse), + }, + { + label: "Image GET", + flow: { + ...base, + id: "fl_image", + url: "https://cdn.example.com/avatars/42.png?size=2", + contentType: "image/png", + requestHeaders: [{ name: "Accept", value: "image/*" }], + responseHeaders: [{ name: "Content-Type", value: "image/png" }], + responseSize: png.length, + }, + requestBody: body(""), + responseBody: body(png), + }, + { + label: "Failed", + flow: { + ...base, + id: "fl_failed", + url: "https://unreachable.example.com/health", + status: null, + elapsedHeadersMs: null, + elapsedMs: null, + error: "dns error: failed to lookup address information: nodename nor servname provided", + requestHeaders: [{ name: "Accept", value: "*/*" }], + }, + requestBody: body(""), + responseBody: body(""), + }, +]; diff --git a/apps/yaak-proxy/harness/main.tsx b/apps/yaak-proxy/harness/main.tsx new file mode 100644 index 000000000..f9e2d5c9c --- /dev/null +++ b/apps/yaak-proxy/harness/main.tsx @@ -0,0 +1,20 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { applyThemeToDocument, defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme"; +import { createStore, Provider } from "jotai"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { FlowPanesHarness } from "./FlowPanesHarness"; +import "../main.css"; + +const dark = new URLSearchParams(location.search).get("theme") !== "light"; +applyThemeToDocument(dark ? defaultDarkTheme : defaultLightTheme); + +createRoot(document.getElementById("root") as HTMLElement).render( + + + + + + + , +); diff --git a/apps/yaak-proxy/lib/atoms.ts b/apps/yaak-proxy/lib/atoms.ts new file mode 100644 index 000000000..291ddef95 --- /dev/null +++ b/apps/yaak-proxy/lib/atoms.ts @@ -0,0 +1,24 @@ +import type { Flow } from "@yaakapp-internal/proxy-lib"; +import type { SplitLayoutLayout } from "@yaakapp-internal/ui"; +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; + +/** The sidebar's tree, whose selection narrows the list */ +export const SIDEBAR_TREE_ID = "proxy-sidebar"; + +/** The selected flow as last seen, kept once the window has dropped it so the detail stays */ +export const selectedFlowAtom = atom(null); + +export const selectedFlowIdAtom = atom((get) => get(selectedFlowAtom)?.id ?? null); + +/** The list follows new traffic, rather than being held where it was scrolled or selected */ +export const followingAtom = atom(true); + +/** When the helper was last registered, so its state is read until approval shows */ +export const helperRegisteredAtAtom = atom(null); + +/** The dialog that walks through allowing the helper in System Settings */ +export const helperDialogOpenAtom = atom(false); + +/** Stacked by default; the header's toggle switches to side by side */ +export const flowLayoutAtom = atomWithStorage("flow_layout", "vertical"); diff --git a/apps/yaak-proxy/lib/body.test.ts b/apps/yaak-proxy/lib/body.test.ts new file mode 100644 index 000000000..804d3d683 --- /dev/null +++ b/apps/yaak-proxy/lib/body.test.ts @@ -0,0 +1,227 @@ +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import type { Flow } from "@yaakapp-internal/proxy-lib"; +import { describe, expect, test, vi } from "vite-plus/test"; +import { + bodyAsText, + bodyFileName, + bodyImageType, + bytesToText, + flowBodyQueryOptions, + viewerText, +} from "./body"; +import { rpc } from "./rpc"; + +vi.mock("./rpc", () => ({ rpc: vi.fn() })); + +const encode = (text: string) => new TextEncoder().encode(text); + +describe("bytesToText", () => { + test("decodes UTF-8", () => { + expect(bytesToText(encode("abc€"))).toBe("abc€"); + }); + + test("rejects a body that is not UTF-8", () => { + expect(bytesToText(new Uint8Array([0xff, 0xfe, 0x00]))).toBeNull(); + }); + + test("keeps a truncated body cut partway through a character", () => { + const cut = encode("abc€").subarray(0, 5); + expect(bytesToText(cut)).toBeNull(); + expect(bytesToText(cut, true)).toBe("abc"); + }); +}); + +describe("bodyAsText", () => { + test("reads UTF-8 as text", () => { + expect(bodyAsText(encode("

Forbidden

\n\tcafé"))).toBe("

Forbidden

\n\tcafé"); + }); + + test("reads unlabeled Latin-1 text as text, as windows-1252", () => { + const page = "

Accès refusé : vous n'avez pas l'autorisation d'accéder à cette page.

"; + const latin1 = Uint8Array.from(page, (c) => c.charCodeAt(0)); + expect(bodyAsText(latin1)).toBe(page); + }); + + test("reads bytes UTF-8 can't read as binary, though windows-1252 reads every one", () => { + let seed = 7; + const random = Uint8Array.from({ length: 600 }, () => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed >> 16; + }); + expect(bodyAsText(random)).toBeNull(); + // All high bytes: letters and symbols in windows-1252, none of them UTF-8 + const high = Uint8Array.from({ length: 600 }, (_, i) => 0xa0 + (i % 96)); + expect(bodyAsText(high)).toBeNull(); + expect(bodyAsText(high, false, "text/plain")).toBeNull(); + }); + + test("reads protobuf and other binary as binary, even when it is valid UTF-8", () => { + // Field tags, lengths and varints: control characters between short strings. + const protobuf = new Uint8Array([ + 0x0a, 0x04, 0x6b, 0x65, 0x79, 0x31, 0x12, 0x02, 0x08, 0x01, 0x1a, 0x03, 0x10, 0x96, 0x01, + 0x22, 0x00, 0x2a, 0x02, 0x18, 0x07, + ]); + expect(bodyAsText(protobuf)).toBeNull(); + expect(bodyAsText(new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x41, 0x42]))).toBeNull(); + expect(bodyAsText(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))).toBeNull(); + }); +}); + +describe("viewerText", () => { + // A PNG signature, then a tEXt chunk of printable metadata far longer than the image + const png = new Uint8Array([ + 0x89, + ...encode("PNG\r\n"), + 0x1a, + 0x0a, + 0x00, + 0x00, + 0x12, + 0xc0, + ...encode(`tEXtDescription\0${"A long printable description. ".repeat(160)}`), + ]); + + test("keeps an image with printable metadata an image", () => { + expect(bodyAsText(png)).not.toBeNull(); + expect(viewerText(png, false, "image/png")).toBeNull(); + }); + + test("reads text sent as an image as text, and other types text first", () => { + expect(viewerText(encode("

Forbidden

"), false, "image/png")).toBe("

Forbidden

"); + expect(bodyImageType(encode("

Forbidden

"), false, "image/png")).toBeNull(); + expect(viewerText(encode(""), false, "image/svg+xml")).toBe(""); + expect(bodyImageType(encode(""), false, "image/svg+xml")).toBeNull(); + const bmwText = encode("BMW sells cars.\n"); + expect(viewerText(bmwText, false, "text/plain")).toBe("BMW sells cars.\n"); + }); + + // A 1×1 black 24-bit BMP: every byte below 0x80, so valid UTF-8 + // File header, then the 40-byte DIB header, then one pixel padded to four bytes + // prettier-ignore + const bmp = new Uint8Array([ + 0x42, 0x4d, 58, 0, 0, 0, 0, 0, 0, 0, 54, 0, 0, 0, + 40, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 24, 0, 0, 0, 0, 0, 4, 0, 0, 0, + 0x13, 0x0b, 0, 0, 0x13, 0x0b, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, + ]); + + test("keeps an image that is valid UTF-8 an image", () => { + expect(bytesToText(bmp)).not.toBeNull(); + expect(bodyImageType(bmp, false, "image/bmp")).toBe("image/bmp"); + expect(viewerText(bmp, false, "image/bmp")).toBeNull(); + }); + + test("previews an image by its signature whatever its content type", () => { + expect(bodyImageType(bmp, false, "application/octet-stream")).toBe("image/bmp"); + expect(bodyImageType(png, false, "application/octet-stream")).toBe("image/png"); + expect(bodyImageType(png, false, null)).toBe("image/png"); + expect(viewerText(png, false, "application/octet-stream")).toBeNull(); + const gif = encode("GIF89a\x01\0\x01\0"); + expect(bodyImageType(gif, false, "text/plain")).toBe("image/gif"); + }); + + test("previews formats not every webview draws only when their content type says so", () => { + const tiff = new Uint8Array([0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00, 0xff, 0x80]); + const heic = new Uint8Array([0, 0, 0, 0x18, ...encode("ftypheic"), 0, 0, 0, 0, 0xff, 0x80]); + for (const image of [tiff, heic]) { + expect(bodyImageType(image, false, "application/octet-stream")).toBeNull(); + expect(viewerText(image, false, "application/octet-stream")).toBeNull(); + } + expect(bodyImageType(tiff, false, "image/tiff")).toBe("image/tiff"); + expect(bodyImageType(heic, false, "image/heic")).toBe("image/heic"); + }); + + test("reads text in the charset its content type names", () => { + const page = "Accès refusé : vous n'avez pas l'autorisation d'accéder à cette page."; + const latin1 = Uint8Array.from(page, (c) => c.charCodeAt(0)); + expect(viewerText(latin1, false, "text/html; charset=iso-8859-1")).toBe(page); + // "こんにちは、世界" in Shift_JIS + const shiftJis = new Uint8Array([ + 0x82, 0xb1, 0x82, 0xf1, 0x82, 0xc9, 0x82, 0xbf, 0x82, 0xcd, 0x81, 0x41, 0x90, 0xa2, 0x8a, + 0x45, + ]); + expect(viewerText(shiftJis, false, "text/plain; charset=shift_jis")).toBe("こんにちは、世界"); + }); + + test("reads unlabeled Latin-1 as windows-1252, and UTF-8 as UTF-8", () => { + const page = "Accès refusé : vous n'avez pas l'autorisation d'accéder à cette page."; + const latin1 = Uint8Array.from(page, (c) => c.charCodeAt(0)); + expect(viewerText(latin1, false, "application/octet-stream")).toBe(page); + expect(viewerText(encode(page), false, "application/octet-stream")).toBe(page); + expect(viewerText(encode(page), false, "text/plain; charset=utf-8")).toBe(page); + }); +}); + +const flow = (url: string, contentType: string | null) => + ({ + id: "fl_1", + url, + updatedAt: "2026-11-04T00:00:00", + requestHeaders: contentType == null ? [] : [{ name: "Content-Type", value: contentType }], + responseHeaders: contentType == null ? [] : [{ name: "content-type", value: contentType }], + }) as unknown as Flow; + +describe("bodyFileName", () => { + test("uses the URL's file name for a response", () => { + expect(bodyFileName(flow("https://a.test/img/logo%20big.png?v=2", null), "response")).toBe( + "logo big.png", + ); + }); + + test("names a body from its content type otherwise", () => { + const protobuf = flow("https://a.test/v1:GetModels", "application/x-protobuf"); + expect(bodyFileName(protobuf, "request")).toBe("request.protobuf"); + expect(bodyFileName(protobuf, "response")).toBe("response.protobuf"); + expect(bodyFileName(flow("https://a.test/", "text/plain; charset=utf-8"), "response")).toBe( + "response.txt", + ); + expect(bodyFileName(flow("https://a.test/api", "application/problem+json"), "response")).toBe( + "response.json", + ); + expect(bodyFileName(flow("https://a.test/data.json", null), "request")).toBe("request.bin"); + expect(bodyFileName(flow("not a url", "application/octet-stream"), "response")).toBe( + "response.bin", + ); + }); +}); + +describe("flowBodyQueryOptions", () => { + test("shows a body that fails to read at once, without retrying", async () => { + vi.mocked(rpc).mockRejectedValue(new Error("No flow fl_1")); + // React Query retries three times in a browser, and not at all where there is no window. + const client = new QueryClient({ defaultOptions: { queries: { retry: 3 } } }); + const observer = new QueryObserver( + client, + flowBodyQueryOptions(flow("https://a.test/", null), "response"), + ); + const failed = new Promise((resolve) => { + const unsubscribe = observer.subscribe((result) => { + if (result.status === "error") { + unsubscribe(); + resolve(result.error.message); + } + }); + }); + // React Query's default retries would keep it pending for seconds. + const timeout = new Promise((resolve) => setTimeout(() => resolve("pending"), 500)); + expect(await Promise.race([failed, timeout])).toBe("No flow fl_1"); + expect(rpc).toHaveBeenCalledTimes(1); + client.clear(); + }); + + test("passes on why a body failed to decode, with its bytes as they were", async () => { + vi.mocked(rpc).mockResolvedValue({ + data: btoa("\x78\x9c\x01"), + truncated: false, + storedSize: 3, + decodeError: "Failed to decode Deflate body: deflate decompression error", + }); + const client = new QueryClient(); + const body = await client.fetchQuery( + flowBodyQueryOptions(flow("https://a.test/", null), "request"), + ); + expect(Array.from(body.bytes)).toEqual([0x78, 0x9c, 0x01]); + expect(body.decodeError).toBe("Failed to decode Deflate body: deflate decompression error"); + client.clear(); + }); +}); diff --git a/apps/yaak-proxy/lib/body.ts b/apps/yaak-proxy/lib/body.ts new file mode 100644 index 000000000..ae8d896ca --- /dev/null +++ b/apps/yaak-proxy/lib/body.ts @@ -0,0 +1,223 @@ +import { queryOptions } from "@tanstack/react-query"; +import { + contentTypeCharset, + decodeBodyText, + strictUtf8Text, +} from "@yaakapp-internal/lib/decodeBodyText"; +import type { FilterLanguage, Flow, FlowBody } from "@yaakapp-internal/proxy-lib"; +import type { CodeEditorProps } from "@yaakapp-internal/ui/editor"; +import vkBeautify from "vkbeautify"; +import { rpc } from "./rpc"; + +/** A flow's body for the viewer: decoded, or as it was on the wire when `decodeError` is set */ +export function flowBodyQueryOptions(flow: Flow, which: FlowBody) { + return queryOptions({ + queryKey: ["get_flow_body", flow.id, which, flow.updatedAt], + queryFn: async () => { + const res = await rpc("get_flow_body", { flowId: flow.id, which }); + return { + bytes: base64ToBytes(res.data), + truncated: res.truncated, + storedSize: res.storedSize, + decodeError: res.decodeError ?? null, + }; + }, + // Reading a stored body fails the same way every time, so the error shows at once. + retry: false, + }); +} + +export function base64ToBytes(data: string): Uint8Array { + const binary = atob(data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/** + * The text of a body, or null when it isn't valid UTF-8. A truncated body may end partway + * through a character, so up to three trailing bytes are dropped before giving up on it. + */ +export const bytesToText = strictUtf8Text; + +/** Characters looked at to tell text from binary */ +const TEXT_SAMPLE = 64 * 1024; + +/** The share of those that may be control characters or undecodable bytes in text */ +const MAX_UNPRINTABLE = 0.1; + +/** + * The body as text, whatever its content type says, or null when it is binary. It is decoded in + * its content type's charset, or else as UTF-8, or windows-1252 when it isn't valid UTF-8. It is + * binary when it is full of control characters, as protobuf is, or, without a charset, of bytes + * UTF-8 can't read: windows-1252 reads every byte as something, compressed data included. + */ +export function bodyAsText( + bytes: Uint8Array, + truncated = false, + contentType: string | null = null, +): string | null { + const text = decodeBodyText(bytes, contentType, { truncated }); + const judged = + contentTypeCharset(contentType) != null + ? text + : (strictUtf8Text(bytes, truncated) ?? new TextDecoder("utf-8").decode(bytes)); + let total = 0; + let unprintable = 0; + for (const char of judged) { + if (total === TEXT_SAMPLE) break; + total++; + const code = char.codePointAt(0) ?? 0; + const control = code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d; + if (control || code === 0x7f || code === 0xfffd) unprintable++; + } + return unprintable <= total * MAX_UNPRINTABLE ? text : null; +} + +const ascii = (bytes: Uint8Array, start: number, text: string) => + Array.from(text).every((c, i) => bytes[start + i] === c.charCodeAt(0)); + +/** + * The image type a body's first bytes name, or null when they name none. Only types every + * platform's webview draws, so a body sniffed as an image always previews: TIFF, HEIC and AVIF + * preview only when their content type says so. + */ +export function imageSignature(bytes: Uint8Array): string | null { + const b = bytes; + if (b.length < 4) return null; + if (b[0] === 0x89 && ascii(b, 1, "PNG\r\n\x1a\n")) return "image/png"; + if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg"; + if (ascii(b, 0, "GIF87a") || ascii(b, 0, "GIF89a")) return "image/gif"; + if (ascii(b, 0, "RIFF") && ascii(b, 8, "WEBP")) return "image/webp"; + if (ascii(b, 0, "\0\0\x01\0") && b.length >= 6 && (b[4] ?? 0) + (b[5] ?? 0) > 0) { + return "image/x-icon"; + } + // "BM" alone starts plenty of text, so the reserved bytes and DIB header size must fit too. + const dibSize = (b[14] ?? 0) | ((b[15] ?? 0) << 8) | ((b[16] ?? 0) << 16) | ((b[17] ?? 0) << 24); + if ( + ascii(b, 0, "BM") && + ascii(b, 6, "\0\0\0\0") && + [12, 40, 52, 56, 64, 108, 124].includes(dibSize) + ) { + return "image/bmp"; + } + return null; +} + +/** + * The image type to preview a body as, or null when it isn't one: what its first bytes name, + * whatever its content type, or else an `image/*` content type other than SVG, unless the body + * is genuinely text, as an error page sent with the image's type is. + */ +export function bodyImageType( + bytes: Uint8Array, + truncated: boolean, + contentType: string | null, +): string | null { + const signed = imageSignature(bytes); + if (signed != null) return signed; + const mime = mimeEssence(contentType) ?? ""; + if (!mime.startsWith("image/") || mime.startsWith("image/svg")) return null; + const genuineText = + strictUtf8Text(bytes, truncated) != null && bodyAsText(bytes, truncated, contentType) != null; + return genuineText ? null : mime; +} + +/** + * The body as the viewer reads it: text first, whatever its content type, except an image, which + * is never text. + */ +export function viewerText( + bytes: Uint8Array, + truncated: boolean, + contentType: string | null, +): string | null { + if (bodyImageType(bytes, truncated, contentType) != null) return null; + return bodyAsText(bytes, truncated, contentType); +} + +export async function formatText( + text: string, + language: CodeEditorProps["language"], +): Promise { + if (text === "") return text; + try { + if (language === "json") return await rpc("format_body", { text }); + if (language === "xml" || language === "html") return vkBeautify.xml(text, " "); + } catch (err) { + console.warn("Failed to format body", err); + } + return text; +} + +/** The filter syntax for a body the viewer detected as `language`: XPath for XML and HTML */ +export function filterLanguage(language: CodeEditorProps["language"]): FilterLanguage | undefined { + if (language === "json") return "jsonpath"; + if (language === "xml" || language === "html") return "xpath"; + return undefined; +} + +export async function filterText( + flowId: string, + which: FlowBody, + expression: string, + language: CodeEditorProps["language"], +): Promise { + const result = await rpc("filter_body", { + flowId, + which, + expression, + language: filterLanguage(language), + }); + if (result.error != null) throw new Error(result.error); + return result.content; +} + +/** The media type without parameters, lowercased: `application/json` */ +export function mimeEssence(contentType: string | null): string | null { + const essence = contentType?.split(";")[0]?.trim().toLowerCase(); + return essence ? essence : null; +} + +/** What the flow list shows for a content type: `json` for `application/problem+json` */ +export function shortContentType(contentType: string | null): string | null { + const essence = mimeEssence(contentType); + if (essence == null) return null; + const subtype = essence.split("/")[1] ?? essence; + const suffix = subtype.split("+").pop() ?? subtype; + return suffix.replace(/^x-/, ""); +} + +/** Extensions for media types whose subtype isn't one */ +const EXTENSIONS: Record = { + "application/javascript": "js", + "application/octet-stream": "bin", + "image/jpeg": "jpg", + "image/svg+xml": "svg", + "text/javascript": "js", + "text/markdown": "md", + "text/plain": "txt", +}; + +/** A name to offer for a saved body: the URL's file name, or one from the content type */ +export function bodyFileName(flow: Flow, which: FlowBody): string { + let last = ""; + try { + last = decodeURIComponent(new URL(flow.url).pathname.split("/").pop() ?? ""); + } catch { + // Not a URL, or not encoded properly + } + if (which === "response" && /^[^\\/:*?"<>|]+\.[a-z0-9]+$/i.test(last)) return last; + const headers = which === "request" ? flow.requestHeaders : flow.responseHeaders; + const contentType = findHeader(headers, "content-type"); + const essence = mimeEssence(contentType); + const extension = (essence == null ? null : EXTENSIONS[essence]) ?? shortContentType(contentType); + return `${which}.${extension?.replace(/[^a-z0-9-]/gi, "") || "bin"}`; +} + +export function findHeader(headers: { name: string; value: string }[], name: string) { + const lower = name.toLowerCase(); + return headers.find((h) => h.name.toLowerCase() === lower)?.value ?? null; +} diff --git a/apps/yaak-proxy/lib/curl.test.ts b/apps/yaak-proxy/lib/curl.test.ts new file mode 100644 index 000000000..5c04a5c23 --- /dev/null +++ b/apps/yaak-proxy/lib/curl.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "vite-plus/test"; +import { curlBodyText, shellQuote, toCurl } from "./curl"; + +const headers = (...pairs: [string, string][]) => pairs.map(([name, value]) => ({ name, value })); + +describe("toCurl", () => { + test("a GET is the URL and its headers, without the ones curl sets itself", () => { + const flow = { + method: "GET", + url: "https://api.github.com/repos/yaak?page=2", + requestHeaders: headers( + ["Host", "api.github.com"], + ["Accept", "application/json"], + ["Proxy-Connection", "keep-alive"], + ["Connection", "keep-alive"], + ), + }; + expect(toCurl(flow, null)).toBe( + "curl 'https://api.github.com/repos/yaak?page=2' \\\n -H 'Accept: application/json'", + ); + }); + + test("a POST with a body leaves the method to curl and quotes the body", () => { + const flow = { + method: "POST", + url: "https://api.stripe.com/v1/charges", + requestHeaders: headers( + ["Authorization", "Bearer sk_test_123"], + ["Content-Type", "application/json"], + ["Content-Length", "23"], + ["Accept-Encoding", "gzip, br"], + ), + }; + expect(toCurl(flow, `{"note":"it's paid"}`)).toBe( + [ + "curl 'https://api.stripe.com/v1/charges'", + "-H 'Authorization: Bearer sk_test_123'", + "-H 'Content-Type: application/json'", + "--compressed", + `--data-raw '{"note":"it'\\''s paid"}'`, + ].join(" \\\n "), + ); + }); + + test("quotes a method that is not a plain word, and drops the decoded body's encoding", () => { + const flow = { + method: "`id`", + url: "https://x.test/a", + requestHeaders: headers(["Content-Encoding", "gzip"], ["X-Trace", "1"]), + }; + expect(toCurl(flow, "plain")).toBe( + "curl -X '`id`' \\\n 'https://x.test/a' \\\n -H 'X-Trace: 1' \\\n --data-raw 'plain'", + ); + }); + + test("replays HEAD with --head and leaves brackets in the URL alone", () => { + const flow = { + method: "HEAD", + url: "https://x.test/a?filter[status]=open", + requestHeaders: [], + }; + expect(toCurl(flow, null)).toBe( + "curl --head \\\n --globoff \\\n 'https://x.test/a?filter[status]=open'", + ); + }); + + test("names any other method", () => { + const flow = { method: "DELETE", url: "https://x.test/a", requestHeaders: [] }; + expect(toCurl(flow, null)).toBe("curl -X DELETE \\\n 'https://x.test/a'"); + expect(toCurl({ ...flow, method: "GET" }, "q=1")).toBe( + "curl -X GET \\\n 'https://x.test/a' \\\n --data-raw 'q=1'", + ); + }); +}); + +describe("shellQuote", () => { + test("closes and reopens the quotes around a quote", () => { + expect(shellQuote("a'b")).toBe(`'a'\\''b'`); + expect(shellQuote("$HOME `x`")).toBe("'$HOME `x`'"); + }); +}); + +describe("curlBodyText", () => { + test("is the text of a complete UTF-8 body", () => { + expect(curlBodyText(new TextEncoder().encode('{"note":"héllo"}'), false)).toBe( + '{"note":"héllo"}', + ); + }); + + test("refuses a body curl would send different bytes for", () => { + expect(curlBodyText(new Uint8Array([0xff, 0x00, 0xfe]), false)).toBeNull(); + expect(curlBodyText(new TextEncoder().encode("the first part"), true)).toBeNull(); + }); +}); diff --git a/apps/yaak-proxy/lib/curl.ts b/apps/yaak-proxy/lib/curl.ts new file mode 100644 index 000000000..2327e42cd --- /dev/null +++ b/apps/yaak-proxy/lib/curl.ts @@ -0,0 +1,68 @@ +import type { Flow } from "@yaakapp-internal/proxy-lib"; + +/** Headers curl works out itself, or that only meant something between the app and the proxy */ +const LEFT_OUT = new Set([ + "host", + "content-length", + "connection", + "keep-alive", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +/** + * A request body as the text a curl command can carry, or null when it would not send the same + * bytes: when it is not valid UTF-8, or only its start was captured. + */ +export function curlBodyText(bytes: Uint8Array, truncated: boolean): string | null { + if (truncated) return null; + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return null; + } +} + +/** `value` in single quotes for a POSIX shell */ +export function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +/** + * The flow's request as a curl command: its method, URL, headers and `body`, the request body + * decoded. Headers curl sets itself are left out, and an encoded response is asked for with + * `--compressed`, as the app did. + */ +export function toCurl(flow: Pick, body: string | null) { + const args: string[] = []; + const hasBody = body != null && body !== ""; + const defaultMethod = hasBody ? "POST" : "GET"; + if (flow.method.toUpperCase() === "HEAD" && !hasBody) { + // So curl expects no body after the headers. + args.push("--head"); + } else if (flow.method.toUpperCase() !== defaultMethod) { + // A method is any token, which can hold characters a shell would act on. + const plain = /^[A-Za-z0-9_-]+$/.test(flow.method); + args.push(`-X ${plain ? flow.method : shellQuote(flow.method)}`); + } + // curl reads brackets and braces in a URL as ranges and sets unless told not to. + if (/[[\]{}]/.test(flow.url)) args.push("--globoff"); + args.push(shellQuote(flow.url)); + let compressed = false; + for (const { name, value } of flow.requestHeaders) { + const lower = name.toLowerCase(); + if (LEFT_OUT.has(lower) || lower.startsWith("proxy-") || lower.startsWith(":")) continue; + // The body is the decoded one, so its encoding no longer applies. + if (lower === "content-encoding" && hasBody) continue; + if (lower === "accept-encoding") { + compressed = true; + continue; + } + args.push(`-H ${shellQuote(`${name}: ${value}`)}`); + } + if (compressed) args.push("--compressed"); + if (hasBody) args.push(`--data-raw ${shellQuote(body)}`); + return `curl ${args.join(" \\\n ")}`; +} diff --git a/apps/yaak-proxy/lib/env.test.ts b/apps/yaak-proxy/lib/env.test.ts new file mode 100644 index 000000000..0bd24dd08 --- /dev/null +++ b/apps/yaak-proxy/lib/env.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "vite-plus/test"; +import { environmentExports } from "./env"; + +describe("environmentExports", () => { + test("points the proxy variables at the port and the CA variables at the certificate", () => { + const path = "/Users/me/Library/Application Support/app.yaak.proxy/ca/ca.pem"; + expect(environmentExports(9090, path)).toBe( + [ + "export HTTP_PROXY=http://127.0.0.1:9090", + "export HTTPS_PROXY=http://127.0.0.1:9090", + "export http_proxy=http://127.0.0.1:9090", + "export https_proxy=http://127.0.0.1:9090", + `export SSL_CERT_FILE='${path}'`, + `export NODE_EXTRA_CA_CERTS='${path}'`, + `export REQUESTS_CA_BUNDLE='${path}'`, + ].join("\n"), + ); + }); +}); diff --git a/apps/yaak-proxy/lib/env.ts b/apps/yaak-proxy/lib/env.ts new file mode 100644 index 000000000..1b7511fa7 --- /dev/null +++ b/apps/yaak-proxy/lib/env.ts @@ -0,0 +1,21 @@ +import { shellQuote } from "./curl"; + +/** + * Shell exports that send one terminal's traffic through the proxy on `port` and trust its CA at + * `caPath`, for tools that read neither the system proxy nor the login keychain: Node, Python, + * and anything built on OpenSSL. + */ +export function environmentExports(port: number, caPath: string): string { + const proxy = `http://127.0.0.1:${port}`; + const ca = shellQuote(caPath); + return [ + `export HTTP_PROXY=${proxy}`, + `export HTTPS_PROXY=${proxy}`, + // curl reads only the lowercase name for plain HTTP. + `export http_proxy=${proxy}`, + `export https_proxy=${proxy}`, + `export SSL_CERT_FILE=${ca}`, + `export NODE_EXTRA_CA_CERTS=${ca}`, + `export REQUESTS_CA_BUNDLE=${ca}`, + ].join("\n"); +} diff --git a/apps/yaak-proxy/lib/flowActions.ts b/apps/yaak-proxy/lib/flowActions.ts new file mode 100644 index 000000000..c019c6937 --- /dev/null +++ b/apps/yaak-proxy/lib/flowActions.ts @@ -0,0 +1,164 @@ +import type { ExportHarRequest, Flow, FlowBody } from "@yaakapp-internal/proxy-lib"; +import type { DropdownItem } from "@yaakapp-internal/ui"; +import { save } from "@tauri-apps/plugin-dialog"; +import { formatSize } from "@yaakapp-internal/lib/formatSize"; +import { atom, useSetAtom } from "jotai"; +import { useCallback } from "react"; +import { base64ToBytes, bodyFileName } from "./body"; +import { curlBodyText, toCurl } from "./curl"; +import { rpc } from "./rpc"; +import { useShowToast } from "./toast"; + +/** The flow the save dialog is open for */ +export const saveDialogFlowAtom = atom(null); + +/** A body as text, decoding what is not UTF-8 lossily, or null when there is none */ +async function bodyText(flow: Flow, which: FlowBody): Promise { + const res = await rpc("get_flow_body", { flowId: flow.id, which }); + if (res.decodeError != null) throw new Error(res.decodeError); + const bytes = base64ToBytes(res.data); + return bytes.length === 0 ? null : new TextDecoder().decode(bytes); +} + +/** Writes a flow's body to a file the person picks, as the viewer shows it */ +export function useSaveFlowBody(): (flow: Flow, which: FlowBody) => Promise { + const showToast = useShowToast(); + return useCallback( + async (flow: Flow, which: FlowBody) => { + try { + const path = await save({ defaultPath: bodyFileName(flow, which) }); + if (path == null) return; + const saved = await rpc("save_flow_body", { flowId: flow.id, which, path }); + const notes = [ + saved.raw ? "as it was on the wire, since it failed to decode" : null, + saved.truncated ? "cut at the capture limit" : null, + ].filter((n) => n != null); + showToast({ + message: `Saved ${formatSize(saved.size)}${notes.length > 0 ? `, ${notes.join(", ")}` : ""}`, + }); + } catch (err) { + showToast({ message: `Failed to save the ${which} body: ${String(err)}`, color: "danger" }); + } + }, + [showToast], + ); +} + +/** + * Writes flows to a HAR file the person picks: `ids`, or every flow `filter` covers. Returns how + * many were written, or null when the save panel was cancelled. + */ +export async function exportHar( + req: Omit, + defaultName: string, +): Promise { + const path = await save({ + defaultPath: defaultName, + filters: [{ name: "HTTP Archive", extensions: ["har"] }], + }); + if (path == null) return null; + return rpc("export_har", { ...req, path }); +} + +/** What a flow's context menu and the detail pane's menu offer */ +export function useFlowActions(): (flow: Flow) => DropdownItem[] { + const showToast = useShowToast(); + const openSave = useSetAtom(saveDialogFlowAtom); + const saveBody = useSaveFlowBody(); + + return useCallback( + (flow: Flow) => { + const copy = async (label: string, text: () => Promise | string) => { + try { + const value = await text(); + if (value == null) { + showToast({ message: `There is no ${label} to copy`, color: "info" }); + return; + } + await navigator.clipboard.writeText(value); + showToast({ message: `Copied ${label}` }); + } catch (err) { + showToast({ message: `Failed to copy ${label}: ${String(err)}`, color: "danger" }); + } + }; + const noResponse = (flow.error != null || flow.cancelled) && flow.status == null; + return [ + { + label: flow.savedRequestId == null ? "Save as Yaak Request" : "Save to Yaak Again", + icon: "bookmark", + onSelect: () => openSave(flow), + }, + { type: "separator" }, + { + label: "Copy as cURL", + icon: "square_terminal", + onSelect: async () => { + let body: string | null = null; + if (flow.requestSize > 0) { + let res; + try { + res = await rpc("get_flow_body", { flowId: flow.id, which: "request" }); + } catch (err) { + showToast({ message: `Failed to copy cURL: ${String(err)}`, color: "danger" }); + return; + } + if (res.decodeError != null) { + showToast({ message: `Failed to copy cURL: ${res.decodeError}`, color: "danger" }); + return; + } + body = curlBodyText(base64ToBytes(res.data), res.truncated); + // A command that sends other bytes than the app did would mislead. + if (body == null) { + showToast({ + message: "Body is binary or truncated; export HAR instead", + color: "info", + }); + return; + } + } + await copy("cURL", () => toCurl(flow, body)); + }, + }, + { label: "Copy URL", icon: "copy", onSelect: () => copy("URL", () => flow.url) }, + { + label: "Copy Request Body", + icon: "copy", + disabled: flow.requestSize === 0, + onSelect: () => copy("request body", () => bodyText(flow, "request")), + }, + { + label: "Copy Response Body", + icon: "copy", + disabled: noResponse, + onSelect: () => copy("response body", () => bodyText(flow, "response")), + }, + { type: "separator" }, + { + label: "Save Request Body to File", + icon: "download", + disabled: flow.requestSize === 0, + onSelect: () => saveBody(flow, "request"), + }, + { + label: "Save Response Body to File", + icon: "download", + disabled: noResponse || flow.responseSize === 0, + onSelect: () => saveBody(flow, "response"), + }, + { + label: "Export HAR", + icon: "download", + onSelect: async () => { + try { + const written = await exportHar({ ids: [flow.id] }, "flow.har"); + if (written != null) showToast({ message: "Exported the flow as HAR" }); + } catch (err) { + showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" }); + } + }, + }, + ]; + }, + [openSave, saveBody, showToast], + ); +} diff --git a/apps/yaak-proxy/lib/flowViews.test.ts b/apps/yaak-proxy/lib/flowViews.test.ts new file mode 100644 index 000000000..0902ca2bf --- /dev/null +++ b/apps/yaak-proxy/lib/flowViews.test.ts @@ -0,0 +1,222 @@ +import type { Flow } from "@yaakapp-internal/proxy-lib"; +import { + BODY_TYPE_BINARY, + BODY_TYPE_FORM_MULTIPART, + BODY_TYPE_FORM_URLENCODED, + BODY_TYPE_JSON, + BODY_TYPE_OTHER, +} from "@yaakapp-internal/ui/httpTypes"; +import { describe, expect, test } from "vite-plus/test"; +import { + authFromHeaders, + type FlowBodyData, + flowBodyFromRpc, + flowRequestToView, + flowResponseToView, + queryToPairs, + utcTimestamp, +} from "./flowViews"; + +const flow: Flow = { + id: "fl_1", + createdAt: "2026-11-04T22:10:00Z", + updatedAt: "2026-11-04T22:10:01Z", + startedAt: "2026-11-04T22:10:00Z", + method: "POST", + url: "https://api.example.com/v1/items?limit=1&q=a+b#top", + clientHttpVersion: "HTTP/1.1", + upstreamHttpVersion: "HTTP/2", + remoteAddr: "1.2.3.4:443", + status: 201, + contentType: "application/json", + requestHeaders: [ + { name: "Content-Type", value: "application/json" }, + { name: "Cookie", value: "a=1" }, + ], + responseHeaders: [ + { name: "Content-Type", value: "application/json" }, + { name: "Set-Cookie", value: "b=2; Path=/" }, + ], + requestSize: 7, + responseSize: 12, + requestTruncated: false, + responseTruncated: false, + elapsedHeadersMs: 40, + elapsedMs: 55, + error: null, + cancelled: false, + savedRequestId: null, + clientPid: null, + clientName: null, + clientBundleId: null, + clientPath: null, +}; + +function bytes(text: string): FlowBodyData { + return { bytes: new TextEncoder().encode(text), truncated: false, storedSize: text.length }; +} + +function withContentType(contentType: string): Flow { + return { ...flow, requestHeaders: [{ name: "Content-Type", value: contentType }] }; +} + +describe("flowRequestToView", () => { + test("maps the request line, headers and query", () => { + const view = flowRequestToView(flow, bytes('{"a":1}')); + expect(view.method).toBe("POST"); + expect(view.url).toBe(flow.url); + expect(view.headers.map((h) => h.name)).toEqual(["Content-Type", "Cookie"]); + expect(view.urlParameters.map(({ name, value }) => ({ name, value }))).toEqual([ + { name: "limit", value: "1" }, + { name: "q", value: "a b" }, + ]); + expect(view.contentType).toBe("application/json"); + }); + + test("reads a JSON body as JSON, sent as written", () => { + const view = flowRequestToView(flow, bytes('{"a":1}')); + expect(view.bodyType).toBe(BODY_TYPE_JSON); + expect(view.body).toEqual({ text: '{"a":1}', sendJsonComments: true }); + }); + + test("reads a url-encoded body as form fields", () => { + const view = flowRequestToView( + withContentType("application/x-www-form-urlencoded; charset=utf-8"), + bytes("a=1&b=two+words&c=%26"), + ); + expect(view.bodyType).toBe(BODY_TYPE_FORM_URLENCODED); + expect(view.body.form?.map(({ name, value }) => [name, value])).toEqual([ + ["a", "1"], + ["b", "two words"], + ["c", "&"], + ]); + }); + + test("leaves multipart and binary bodies to the viewers", () => { + expect( + flowRequestToView(withContentType("multipart/form-data; boundary=x"), bytes("--x--")) + .bodyType, + ).toBe(BODY_TYPE_FORM_MULTIPART); + const binary: FlowBodyData = { + bytes: new Uint8Array([0xff, 0xfe, 0x00]), + truncated: false, + storedSize: 3, + }; + expect(flowRequestToView(withContentType("application/octet-stream"), binary).bodyType).toBe( + BODY_TYPE_BINARY, + ); + }); + + test("reads other text as text, and no body as none", () => { + expect(flowRequestToView(withContentType("text/plain"), bytes("hi")).bodyType).toBe( + BODY_TYPE_OTHER, + ); + expect(flowRequestToView(flow, bytes("")).bodyType).toBeNull(); + expect(flowRequestToView(flow, null).bodyType).toBeNull(); + }); + + test("shows authentication from the Authorization header", () => { + expect(authFromHeaders([{ name: "authorization", value: "Bearer abc" }])).toEqual({ + label: "Bearer", + values: [{ name: "Authorization", value: "Bearer abc" }], + }); + expect(authFromHeaders([{ name: "Authorization", value: "Custom x" }])?.label).toBe("Auth"); + expect(authFromHeaders([])).toBeNull(); + }); +}); + +describe("flowResponseToView", () => { + test("maps status, timing, sizes and cookies", () => { + const view = flowResponseToView(flow); + expect(view).toMatchObject({ + id: "fl_1", + ownerId: "fl_1", + state: "closed", + status: 201, + statusReason: "Created", + contentType: "application/json", + contentLength: 12, + requestContentLength: 7, + startedAt: "2026-11-04T22:10:00Z", + elapsedMs: 55, + elapsedHeadersMs: 40, + version: "HTTP/2", + remoteAddr: "1.2.3.4:443", + cookieHeaders: { sent: ["a=1"], received: ["b=2; Path=/"] }, + }); + }); + + test("shows a gzip response's decoded size, with its wire size as compressed", () => { + const gzipped: Flow = { + ...flow, + responseHeaders: [...flow.responseHeaders, { name: "Content-Encoding", value: "gzip" }], + responseSize: 40, + }; + const decoded: FlowBodyData = { + bytes: new Uint8Array(120), + truncated: false, + storedSize: 40, + }; + expect(flowResponseToView(gzipped, decoded)).toMatchObject({ + contentLength: 120, + contentLengthCompressed: 40, + }); + // Before the body loads, the wire size stands in, still marked compressed + expect(flowResponseToView(gzipped)).toMatchObject({ + contentLength: 40, + contentLengthCompressed: 40, + }); + }); + + test("keeps the wire size for an unencoded body cut at the capture limit", () => { + const cut: FlowBodyData = { bytes: new Uint8Array(5), truncated: true, storedSize: 5 }; + expect(flowResponseToView(flow, cut)).toMatchObject({ + contentLength: 12, + contentLengthCompressed: null, + }); + expect(flowResponseToView(flow, bytes("hello world!"))).toMatchObject({ + contentLength: 12, + contentLengthCompressed: null, + }); + }); + + test("reads the engine's zone-less timestamps as UTC", () => { + expect(flowResponseToView({ ...flow, startedAt: "2026-11-04T22:10:00.123" })?.startedAt).toBe( + "2026-11-04T22:10:00.123Z", + ); + expect(utcTimestamp("2026-11-04T22:10:00+02:00")).toBe("2026-11-04T22:10:00+02:00"); + }); + + test("is still connected while the body streams", () => { + expect(flowResponseToView({ ...flow, elapsedMs: null })?.state).toBe("connected"); + }); + + test("has no response while waiting on one", () => { + expect(flowResponseToView({ ...flow, status: null, elapsedMs: null })).toBeNull(); + }); + + test("carries a failure as the response's error", () => { + const view = flowResponseToView({ ...flow, status: null, elapsedMs: null, error: "refused" }); + expect(view).toMatchObject({ state: "closed", status: 0, error: "refused" }); + }); + + test("says when the client gave up before a response", () => { + const view = flowResponseToView({ ...flow, status: null, elapsedMs: null, cancelled: true }); + expect(view?.error).toBe("Cancelled by the client"); + }); +}); + +describe("flowBodyFromRpc", () => { + test("decodes the RPC's base64", () => { + const body = flowBodyFromRpc({ data: btoa("hello"), truncated: true, storedSize: 5 }); + expect(new TextDecoder().decode(body.bytes)).toBe("hello"); + expect(body.truncated).toBe(true); + }); +}); + +describe("queryToPairs", () => { + test("ignores the fragment and keeps repeats", () => { + expect(queryToPairs("https://x.test/?a=1&a=2#b=3").map((p) => p.value)).toEqual(["1", "2"]); + expect(queryToPairs("https://x.test/")).toEqual([]); + }); +}); diff --git a/apps/yaak-proxy/lib/flowViews.ts b/apps/yaak-proxy/lib/flowViews.ts new file mode 100644 index 000000000..f4945e04a --- /dev/null +++ b/apps/yaak-proxy/lib/flowViews.ts @@ -0,0 +1,235 @@ +import type { Flow, GetFlowBodyResponse, ProxyHeader } from "@yaakapp-internal/proxy-lib"; +import { + BODY_TYPE_BINARY, + BODY_TYPE_FORM_MULTIPART, + BODY_TYPE_FORM_URLENCODED, + BODY_TYPE_JSON, + BODY_TYPE_OTHER, + BODY_TYPE_XML, + type HttpAuthView, + type HttpBodyLoader, + type HttpBodyResult, + type HttpPairView, + type HttpRequestBodyView, + type HttpRequestView, + type HttpResponseView, +} from "@yaakapp-internal/ui/httpTypes"; +import { useEffect, useState } from "react"; +import { base64ToBytes, bytesToText, findHeader, mimeEssence } from "./body"; +import { statusReason } from "./statusReason"; + +/** A captured body as the engine's body RPC returns it, decoded */ +export interface FlowBodyData { + bytes: Uint8Array; + truncated: boolean; + storedSize: number; +} + +export function flowBodyFromRpc(res: GetFlowBodyResponse): FlowBodyData { + return { bytes: base64ToBytes(res.data), truncated: res.truncated, storedSize: res.storedSize }; +} + +/** A captured request as the shared request pane shows it, read-only */ +export function flowRequestToView(flow: Flow, body: FlowBodyData | null): HttpRequestView { + const contentType = findHeader(flow.requestHeaders, "content-type"); + const text = body == null ? null : bytesToText(body.bytes, body.truncated); + const { bodyType, body: bodyView } = requestBodyView(contentType, body, text); + + return { + id: flow.id, + method: flow.method, + url: flow.url, + headers: headersToPairs(flow.requestHeaders), + urlParameters: queryToPairs(flow.url), + bodyType, + body: bodyView, + contentType, + auth: authFromHeaders(flow.requestHeaders), + }; +} + +/** The body type a captured body reads as, in the shape that body type's editor shows */ +function requestBodyView( + contentType: string | null, + body: FlowBodyData | null, + text: string | null, +): { bodyType: string | null; body: HttpRequestBodyView } { + if (body == null || body.bytes.length === 0) return { bodyType: null, body: {} }; + + const mime = mimeEssence(contentType) ?? ""; + if (mime.startsWith("multipart/")) return { bodyType: BODY_TYPE_FORM_MULTIPART, body: {} }; + if (text == null) return { bodyType: BODY_TYPE_BINARY, body: {} }; + if (mime === BODY_TYPE_FORM_URLENCODED) { + return { bodyType: BODY_TYPE_FORM_URLENCODED, body: { form: formToPairs(text) } }; + } + if (mime === "application/json" || mime.endsWith("+json")) { + // Captured as sent, so nothing was cleaned up before it went out + return { bodyType: BODY_TYPE_JSON, body: { text, sendJsonComments: true } }; + } + if (mime === "text/xml" || mime === "application/xml" || mime.endsWith("+xml")) { + return { bodyType: BODY_TYPE_XML, body: { text } }; + } + return { bodyType: BODY_TYPE_OTHER, body: { text } }; +} + +/** + * A captured response as the shared response pane shows it, or null when the request never + * got one and didn't fail either (it's still waiting). `body` is the response body from the + * engine, decoded; its size is the one the pane shows, and the wire size counts as compressed + * when the response had a Content-Encoding. Until it loads, the wire size stands in. + */ +export function flowResponseToView( + flow: Flow, + body: FlowBodyData | null = null, +): HttpResponseView | null { + const ended = flow.elapsedMs != null || flow.error != null || flow.cancelled; + if (flow.status == null && !ended) return null; + + const encoding = findHeader(flow.responseHeaders, "content-encoding")?.trim().toLowerCase(); + const encoded = encoding != null && encoding !== "" && encoding !== "identity"; + + return { + id: flow.id, + ownerId: flow.id, + state: ended ? "closed" : "connected", + status: flow.status ?? 0, + statusReason: flow.status == null ? null : statusReason(flow.status), + url: flow.url, + headers: headersToPairs(flow.responseHeaders), + requestHeaders: headersToPairs(flow.requestHeaders), + contentType: findHeader(flow.responseHeaders, "content-type"), + // A body cut at the capture limit is short; unencoded, the wire size is the exact one + contentLength: + body == null || (body.truncated && !encoded) ? flow.responseSize : body.bytes.length, + contentLengthCompressed: encoded ? flow.responseSize : null, + requestContentLength: flow.requestSize, + startedAt: utcTimestamp(flow.startedAt), + elapsedMs: flow.elapsedMs ?? 0, + elapsedHeadersMs: flow.elapsedHeadersMs ?? 0, + elapsedDnsMs: 0, + error: flow.error ?? (flow.cancelled && flow.status == null ? "Cancelled by the client" : null), + remoteAddr: flow.remoteAddr, + version: flow.upstreamHttpVersion ?? flow.clientHttpVersion, + cookieHeaders: { + sent: headerValues(flow.requestHeaders, "cookie"), + received: headerValues(flow.responseHeaders, "set-cookie"), + }, + }; +} + +/** + * Reads a captured body the ways the shared viewers ask for it. `useBody` is the hook that + * loads it, the engine's body RPC in the app or a fixture in tests. + */ +export function flowBodyLoader({ + useBody, + contentType, + filterText, +}: { + useBody: () => HttpBodyResult; + contentType: string | null; + filterText?: (expression: string) => Promise; +}): HttpBodyLoader { + return { + useText: () => { + const body = useBody(); + return { ...body, data: mapData(body, (b) => bytesToText(b.bytes, b.truncated)) }; + }, + useBytes: () => { + const body = useBody(); + return { ...body, data: mapData(body, (b) => b.bytes) }; + }, + useUrl: (enabled: boolean) => { + const body = useBody(); + const bytes = enabled ? (body.data?.bytes ?? null) : null; + const url = useObjectUrl(bytes, contentType); + // The URL follows the bytes by a render + const waiting = bytes != null && bytes.length > 0 && url == null; + return { ...body, isLoading: !enabled || body.isLoading || waiting, data: url }; + }, + filterText, + }; +} + +function mapData( + body: HttpBodyResult, + fn: (b: FlowBodyData) => T, +): T | null | undefined { + if (body.data === undefined) return undefined; + if (body.data == null || body.data.bytes.length === 0) return null; + return fn(body.data); +} + +/** A URL for bytes held in memory, revoked when they change */ +function useObjectUrl(bytes: Uint8Array | null, contentType: string | null) { + const [url, setUrl] = useState(null); + // Made and revoked by the same effect, so a remount can't revoke a URL still in use + useEffect(() => { + if (bytes == null || bytes.length === 0) { + setUrl(null); + return; + } + const next = URL.createObjectURL(new Blob([bytes], { type: mimeEssence(contentType) ?? "" })); + setUrl(next); + return () => URL.revokeObjectURL(next); + }, [bytes, contentType]); + return url; +} + +/** The engine stores UTC without a zone; JavaScript would read that as local time */ +export function utcTimestamp(timestamp: string): string { + return /(Z|[+-]\d\d:?\d\d)$/i.test(timestamp) ? timestamp : `${timestamp}Z`; +} + +function headersToPairs(headers: ProxyHeader[]): HttpPairView[] { + return headers.map((h, i) => ({ id: `${i}`, name: h.name, value: h.value, enabled: true })); +} + +function headerValues(headers: ProxyHeader[], name: string): string[] { + return headers.filter((h) => h.name.toLowerCase() === name).map((h) => h.value); +} + +/** A URL's query string as parameters, in order, repeats kept */ +export function queryToPairs(url: string): HttpPairView[] { + const query = url.split("#")[0]?.split("?").slice(1).join("?") ?? ""; + return formToPairs(query); +} + +/** `a=1&b=2` as pairs, decoded the way a form or query string is */ +export function formToPairs(text: string): HttpPairView[] { + if (text === "") return []; + return text.split("&").map((part, i) => { + const [name = "", ...rest] = part.split("="); + return { + id: `${i}`, + name: decodeFormPart(name), + value: decodeFormPart(rest.join("=")), + enabled: true, + }; + }); +} + +function decodeFormPart(s: string): string { + try { + return decodeURIComponent(s.replaceAll("+", " ")); + } catch { + return s; + } +} + +/** What the request authenticated with, named by its scheme, for display */ +export function authFromHeaders(headers: ProxyHeader[]): HttpAuthView | null { + const value = findHeader(headers, "authorization"); + if (value == null) return null; + const scheme = value.split(" ")[0]?.toLowerCase() ?? ""; + return { label: AUTH_SCHEMES[scheme] ?? "Auth", values: [{ name: "Authorization", value }] }; +} + +const AUTH_SCHEMES: Record = { + basic: "Basic", + bearer: "Bearer", + digest: "Digest", + ntlm: "NTLM", + negotiate: "Negotiate", + "aws4-hmac-sha256": "AWS", +}; diff --git a/apps/yaak-proxy/lib/flowWindow.test.ts b/apps/yaak-proxy/lib/flowWindow.test.ts new file mode 100644 index 000000000..571868684 --- /dev/null +++ b/apps/yaak-proxy/lib/flowWindow.test.ts @@ -0,0 +1,142 @@ +import type { Flow, ModelPayload } from "@yaakapp-internal/proxy-lib"; +import { describe, expect, test } from "vite-plus/test"; +import { + applyChanges, + emptyWindow, + type FlowWindow, + prependOlder, + trimWindow, + windowFromPage, +} from "./flowWindow"; + +/** A flow started at `second` past 10:00, with an id that sorts the same way */ +function flow(second: number, extra: Partial = {}): Flow { + const s = String(second).padStart(2, "0"); + return { id: `fl_${s}`, startedAt: `2026-10-28T10:00:${s}`, ...extra } as Flow; +} + +const created = (f: Flow): ModelPayload => ({ + model: f, + change: { type: "upsert", created: true }, +}); +const updated = (f: Flow): ModelPayload => ({ + model: f, + change: { type: "upsert", created: false }, +}); +const ids = (win: FlowWindow) => win.flows.map((f) => f.id); + +describe("windowFromPage", () => { + test("holds the newest page oldest first", () => { + const win = windowFromPage([flow(3), flow(2), flow(1)], 3, 10); + expect(ids(win)).toEqual(["fl_01", "fl_02", "fl_03"]); + expect(win).toMatchObject({ hasOlder: true, total: 10 }); + }); + + test("knows there is nothing older when the page came back short", () => { + expect(windowFromPage([flow(1)], 3, 1).hasOlder).toBe(false); + }); +}); + +describe("applyChanges", () => { + test("appends new traffic and counts it", () => { + const win = applyChanges(windowFromPage([flow(2), flow(1)], 10, 2), [ + created(flow(3)), + created(flow(4)), + ]); + expect(ids(win)).toEqual(["fl_01", "fl_02", "fl_03", "fl_04"]); + expect(win.total).toBe(4); + }); + + test("puts a slow flow where it started", () => { + const win = applyChanges(windowFromPage([flow(5), flow(1)], 10, 2), [created(flow(3))]); + expect(ids(win)).toEqual(["fl_01", "fl_03", "fl_05"]); + }); + + test("orders flows that started together by id", () => { + const a = flow(2, { id: "fl_a" }); + const b = flow(2, { id: "fl_b" }); + const win = applyChanges(emptyWindow, [created(b), created(a)]); + expect(ids(win)).toEqual(["fl_a", "fl_b"]); + }); + + test("replaces a changed flow in place without counting it again", () => { + const saved = flow(2, { savedRequestId: "rq_1" }); + const win = applyChanges(windowFromPage([flow(3), flow(2), flow(1)], 10, 3), [updated(saved)]); + expect(win.flows[1]).toBe(saved); + expect(win.total).toBe(3); + }); + + test("replaces a flow the first page already had, without counting it again", () => { + const win = applyChanges(windowFromPage([flow(2), flow(1)], 10, 2), [created(flow(2))]); + expect(ids(win)).toEqual(["fl_01", "fl_02"]); + expect(win.total).toBe(2); + }); + + test("leaves a flow older than the window to paging, but counts it", () => { + const win = applyChanges(windowFromPage([flow(5), flow(4)], 2, 9), [created(flow(1))]); + expect(ids(win)).toEqual(["fl_04", "fl_05"]); + expect(win.total).toBe(10); + }); + + test("keeps an older flow when the window already holds everything", () => { + const win = applyChanges(windowFromPage([flow(5), flow(4)], 10, 2), [created(flow(1))]); + expect(ids(win)).toEqual(["fl_01", "fl_04", "fl_05"]); + }); + + test("returns the same window for no changes", () => { + const win = windowFromPage([flow(1)], 10, 1); + expect(applyChanges(win, [])).toBe(win); + }); +}); + +describe("trimWindow", () => { + const many = (n: number) => + windowFromPage( + Array.from({ length: n }, (_, i) => flow(n - i)), + 1000, + n, + ); + + test("leaves the window alone within the slack", () => { + const win = many(7); + expect(trimWindow(win, 4, 3)).toBe(win); + }); + + test("drops the oldest back to the size, an even number of them", () => { + const even = trimWindow(many(8), 4, 3); + expect(ids(even)).toEqual(["fl_05", "fl_06", "fl_07", "fl_08"]); + expect(even).toMatchObject({ hasOlder: true, total: 8 }); + // Five over rounds up to six, so the list's stripes stay on the same flows. + expect(ids(trimWindow(many(9), 4, 3))).toEqual(["fl_07", "fl_08", "fl_09"]); + }); +}); + +describe("prependOlder", () => { + test("adds an older page before the oldest flow held", () => { + const win = prependOlder(windowFromPage([flow(5), flow(4)], 2, 5), [flow(3), flow(2)], 2); + expect(ids(win)).toEqual(["fl_02", "fl_03", "fl_04", "fl_05"]); + expect(win.hasOlder).toBe(true); + }); + + test("knows it reached the start when a page comes back short", () => { + const win = prependOlder(windowFromPage([flow(5), flow(4)], 2, 3), [flow(1)], 2); + expect(ids(win)).toEqual(["fl_01", "fl_04", "fl_05"]); + expect(win.hasOlder).toBe(false); + }); + + test("skips flows the window already has", () => { + const win = prependOlder(windowFromPage([flow(5), flow(4)], 2, 5), [flow(4), flow(3)], 2); + expect(ids(win)).toEqual(["fl_03", "fl_04", "fl_05"]); + }); + + test("keeps a slow flow that ended while the page loaded, once its changes apply again", () => { + const before = windowFromPage([flow(5), flow(4)], 2, 5); + // Ended mid-request, older than the window, so left out, and missing from the page. + const slow = created(flow(2)); + const live = applyChanges(before, [slow]); + expect(ids(live)).toEqual(["fl_04", "fl_05"]); + const merged = prependOlder(live, [flow(3), flow(1)], 2); + const replayed = applyChanges(merged, [slow]); + expect(ids(replayed)).toEqual(["fl_01", "fl_02", "fl_03", "fl_04", "fl_05"]); + }); +}); diff --git a/apps/yaak-proxy/lib/flowWindow.ts b/apps/yaak-proxy/lib/flowWindow.ts new file mode 100644 index 000000000..83caf225b --- /dev/null +++ b/apps/yaak-proxy/lib/flowWindow.ts @@ -0,0 +1,100 @@ +import type { Flow, ModelPayload } from "@yaakapp-internal/proxy-lib"; + +/** Flows the window keeps while it follows new traffic */ +export const WINDOW_SIZE = 2000; +/** + * Flows past `WINDOW_SIZE` before the oldest are dropped, so they go a few hundred at a time + * rather than one per new flow, which would shift every row of the list each time. + */ +export const WINDOW_SLACK = 500; +/** Older flows loaded per page on scrolling to the top */ +export const PAGE_SIZE = 500; + +/** + * The flows the app holds in memory: the newest in the database, oldest first, ordered by start + * and then id as `list_flows` orders them. The database keeps every flow; older ones load back + * in pages. + */ +export interface FlowWindow { + flows: Flow[]; + /** The database has flows older than the first one here */ + hasOlder: boolean; + /** Flows in the database */ + total: number; +} + +export const emptyWindow: FlowWindow = { flows: [], hasOlder: false, total: 0 }; + +type FlowKey = Pick; + +export function compareFlows(a: FlowKey, b: FlowKey): number { + if (a.startedAt !== b.startedAt) return a.startedAt < b.startedAt ? -1 : 1; + if (a.id !== b.id) return a.id < b.id ? -1 : 1; + return 0; +} + +/** The window from the newest page of `list_flows`, which comes newest first */ +export function windowFromPage(page: Flow[], limit: number, total: number): FlowWindow { + return { flows: [...page].reverse(), hasOlder: page.length >= limit, total }; +} + +/** + * Adds new flows where they belong and replaces changed ones. New traffic almost always lands at + * the end, so this is an append. A flow that started before the oldest one held, while older ones + * are still in the database, is left for paging to load, so the window never has a gap. + */ +export function applyChanges(win: FlowWindow, changes: ModelPayload[]): FlowWindow { + if (changes.length === 0) return win; + const flows = win.flows.slice(); + let total = win.total; + for (const { model, change } of changes) { + const i = insertionIndex(flows, model); + const existing = flows[i]?.id === model.id; + if (change.type === "delete") { + if (existing) flows.splice(i, 1); + total = Math.max(0, total - 1); + } else if (existing) { + flows[i] = model; + } else { + if (change.created) total++; + const first = flows[0]; + if (win.hasOlder && first != null && compareFlows(model, first) < 0) continue; + flows.splice(i, 0, model); + } + } + return { ...win, flows, total }; +} + +/** + * Drops the oldest flows once there are `slack` more than `size`, back down to `size`. Always an + * even number of them, so the list's alternating rows keep their shade. + */ +export function trimWindow(win: FlowWindow, size = WINDOW_SIZE, slack = WINDOW_SLACK): FlowWindow { + if (win.flows.length <= size + slack) return win; + let drop = win.flows.length - size; + drop += drop % 2; + return { ...win, flows: win.flows.slice(drop), hasOlder: true }; +} + +/** Adds a page of older flows from `list_flows`, newest first, before the oldest one held */ +export function prependOlder(win: FlowWindow, page: Flow[], limit: number): FlowWindow { + const first = win.flows[0]; + const older = page.filter((f) => first == null || compareFlows(f, first) < 0).reverse(); + return { ...win, flows: [...older, ...win.flows], hasOlder: page.length >= limit }; +} + +/** Where `flow` is, or would go, in `flows` */ +function insertionIndex(flows: FlowKey[], flow: FlowKey): number { + // New traffic goes at the end; skip the search for it. + const last = flows[flows.length - 1]; + if (last == null || compareFlows(last, flow) < 0) return flows.length; + let lo = 0; + let hi = flows.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + const item = flows[mid]; + if (item != null && compareFlows(item, flow) < 0) lo = mid + 1; + else hi = mid; + } + return lo; +} diff --git a/apps/yaak-proxy/lib/follow.test.ts b/apps/yaak-proxy/lib/follow.test.ts new file mode 100644 index 000000000..9db5bf3a7 --- /dev/null +++ b/apps/yaak-proxy/lib/follow.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "vite-plus/test"; +import { countNewFlows } from "./follow"; + +const at = (...times: string[]) => times.map((t) => ({ startedAt: `2026-10-26T10:00:${t}` })); + +describe("countNewFlows", () => { + test("counts flows that started after the hold", () => { + expect(countNewFlows(at("01", "02", "03", "04"), "2026-10-26T10:00:02")).toBe(2); + }); + + test("counts every flow when there was no traffic at the hold", () => { + expect(countNewFlows(at("01", "02"), null)).toBe(2); + }); + + test("keeps counting after a filter hides the flow the list was held on", () => { + // Held while the newest flow overall started at :03, then filtered to a view without it + expect(countNewFlows(at("01", "05", "06"), "2026-10-26T10:00:03")).toBe(2); + }); + + test("counts nothing when no flow in the view is new", () => { + expect(countNewFlows(at("01", "02"), "2026-10-26T10:00:03")).toBe(0); + }); + + test("compares start times with and without fractional seconds", () => { + expect(countNewFlows(at("03.5", "04"), "2026-10-26T10:00:03")).toBe(2); + expect(countNewFlows(at("03", "03.25"), "2026-10-26T10:00:03.2")).toBe(1); + }); +}); diff --git a/apps/yaak-proxy/lib/follow.ts b/apps/yaak-proxy/lib/follow.ts new file mode 100644 index 000000000..354e1abb6 --- /dev/null +++ b/apps/yaak-proxy/lib/follow.ts @@ -0,0 +1,17 @@ +import type { Flow } from "@yaakapp-internal/proxy-lib"; + +/** + * Flows in the list that started after the list was held. `heldAt` is the start of the newest + * flow across all traffic at that moment, or null when there was none, so it still holds when + * the sidebar filter changes what the list shows. Expects flows oldest first, like `flowsAtom`. + */ +export function countNewFlows(flows: Pick[], heldAt: string | null): number { + if (heldAt == null) return flows.length; + let count = 0; + for (let i = flows.length - 1; i >= 0; i--) { + const flow = flows[i]; + if (flow == null || flow.startedAt <= heldAt) break; + count++; + } + return count; +} diff --git a/apps/yaak-proxy/lib/hotkeys.ts b/apps/yaak-proxy/lib/hotkeys.ts index 58351377b..06bb9512c 100644 --- a/apps/yaak-proxy/lib/hotkeys.ts +++ b/apps/yaak-proxy/lib/hotkeys.ts @@ -1,14 +1,44 @@ -import type { ActionInvocation, ActionMetadata } from "@yaakapp-internal/proxy-lib"; +import type { ActionInvocation, GlobalAction } from "@yaakapp-internal/proxy-lib"; +import type { HotkeyContextValue, HotKeyOptions } from "@yaakapp-internal/ui"; +import { useEffect, useMemo } from "react"; +import { useRpcQuery } from "../hooks/useRpcQuery"; import { rpc } from "./rpc"; +import { getOsType } from "./tauri"; -type ActionBinding = { - invocation: ActionInvocation; - meta: ActionMetadata; - keys: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }; -}; +declare module "@yaakapp-internal/ui" { + interface HotkeyActionRegistry { + action: GlobalAction; + } +} + +type Keys = { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }; + +type Binding = { invocation: ActionInvocation; hotkey: string; keys: Keys }; + +interface Callback { + action: GlobalAction; + callback: (e: KeyboardEvent) => void; + options: HotKeyOptions; +} + +let callbacks: Callback[] = []; + +function register( + action: GlobalAction, + callback: (e: KeyboardEvent) => void, + options: HotKeyOptions, +) { + callbacks = [ + ...callbacks.filter((cb) => cb.action !== action || cb.options.priority !== options.priority), + { action, callback, options }, + ].sort((a, b) => (b.options.priority ?? 0) - (a.options.priority ?? 0)); + return () => { + callbacks = callbacks.filter((cb) => cb.callback !== callback); + }; +} /** Parse a hotkey string like "Ctrl+Shift+P" into its parts. */ -function parseHotkey(hotkey: string): ActionBinding["keys"] { +function parseHotkey(hotkey: string): Keys { const parts = hotkey.split("+").map((p) => p.trim().toLowerCase()); return { ctrl: parts.includes("ctrl") || parts.includes("control"), @@ -22,42 +52,64 @@ function parseHotkey(hotkey: string): ActionBinding["keys"] { }; } -function matchesEvent(binding: ActionBinding["keys"], e: KeyboardEvent): boolean { +function matchesEvent(keys: Keys, e: KeyboardEvent): boolean { return ( - e.ctrlKey === binding.ctrl && - e.shiftKey === binding.shift && - e.altKey === binding.alt && - e.metaKey === binding.meta && - e.key.toLowerCase() === binding.key + e.ctrlKey === keys.ctrl && + e.shiftKey === keys.shift && + e.altKey === keys.alt && + e.metaKey === keys.meta && + e.key.toLowerCase() === keys.key ); } -/** Fetch all actions from Rust and register a global keydown listener. */ -export async function initHotkeys(): Promise<() => void> { - const { actions } = await rpc("list_actions", {}); +/** + * Hotkeys from the actions' metadata: a key runs the component bound to its action if one is, + * and the action itself otherwise. + */ +export function useHotkeyContextValue(): HotkeyContextValue { + const { data } = useRpcQuery("list_actions", {}); - const bindings: ActionBinding[] = actions - .filter( - // oxlint-disable-next-line no-redundant-type-constituents -- ActionMetadata resolves at runtime - (entry): entry is [ActionInvocation, ActionMetadata & { defaultHotkey: string }] => - entry[1].defaultHotkey != null, - ) - .map(([invocation, meta]) => ({ - invocation, - meta, - keys: parseHotkey(meta.defaultHotkey), - })); + const bindings = useMemo(() => { + const map = new Map(); + for (const [invocation, meta] of data?.actions ?? []) { + if (meta.defaultHotkey == null) continue; + const hotkey = meta.defaultHotkey.replace(/\bCtrl\b/, "Control"); + map.set(invocation.action, { invocation, hotkey, keys: parseHotkey(hotkey) }); + } + return map; + }, [data]); - function onKeyDown(e: KeyboardEvent) { - for (const binding of bindings) { - if (matchesEvent(binding.keys, e)) { + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + for (const { action, callback, options } of callbacks) { + const enable = typeof options.enable === "function" ? options.enable() : options.enable; + const binding = bindings.get(action); + if (enable === false || binding == null || !matchesEvent(binding.keys, e)) continue; + if (!options.allowDefault) e.preventDefault(); + callback(e); + return; + } + for (const binding of bindings.values()) { + if (!matchesEvent(binding.keys, e)) continue; e.preventDefault(); void rpc("execute_action", binding.invocation); return; } } - } - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [bindings]); + + return useMemo( + () => ({ + os: getOsType(), + bindingFor: (action) => { + const hotkey = bindings.get(action)?.hotkey; + return hotkey == null ? null : [hotkey]; + }, + register, + }), + [bindings], + ); } diff --git a/apps/yaak-proxy/lib/rpc.ts b/apps/yaak-proxy/lib/rpc.ts index fa31c4fcc..0c3d6fec0 100644 --- a/apps/yaak-proxy/lib/rpc.ts +++ b/apps/yaak-proxy/lib/rpc.ts @@ -4,8 +4,13 @@ import { command, subscribe } from "./tauri"; export type Req = RpcSchema[K][0]; export type Res = RpcSchema[K][1]; +/** Call a backend command. A failure rejects with an `Error`, never the bare string Tauri gives. */ export async function rpc(cmd: K, payload: Req): Promise> { - return command>("rpc", { cmd, payload }); + try { + return await command>("rpc", { cmd, payload }); + } catch (err) { + throw err instanceof Error ? err : new Error(String(err)); + } } /** Subscribe to a backend event. Returns an unsubscribe function. */ diff --git a/apps/yaak-proxy/lib/search.test.ts b/apps/yaak-proxy/lib/search.test.ts new file mode 100644 index 000000000..5b60763f7 --- /dev/null +++ b/apps/yaak-proxy/lib/search.test.ts @@ -0,0 +1,143 @@ +import type { Flow } from "@yaakapp-internal/proxy-lib"; +import { describe, expect, test } from "vite-plus/test"; +import { matchesQuery, searchTerms, selectionQuery, sidebarPath } from "./search"; + +function flow(url: string, extra: Partial = {}): Flow { + return { + url, + method: "GET", + status: 200, + contentType: null, + clientBundleId: null, + clientPath: null, + ...extra, + } as Flow; +} + +// The same flows and terms as `search_matches_url_method_status_and_content_type` in +// `flows.rs`, so the live matcher and the database agree. +const stripe = flow("https://api.stripe.com/v1/charges?customer=cus_9", { + method: "POST", + contentType: "application/json; charset=utf-8", +}); +const github = flow("https://api.github.com/repos/yaak", { + status: 404, + contentType: "application/json; charset=utf-8", +}); +const logo = flow("https://images.ctfassets.net/logo.png", { contentType: "image/png" }); +const health = flow("http://localhost:3000/health", { method: "HEAD", status: 503 }); +const all = [stripe, github, logo, health]; + +const search = (q: string) => all.filter((f) => matchesQuery(f, { q })); + +describe("matchesQuery", () => { + test("matches the host, the path and the query, any case", () => { + expect(search("STRIPE")).toEqual([stripe]); + expect(search("/repos/")).toEqual([github]); + expect(search("cus_9")).toEqual([stripe]); + }); + + test("matches the whole method", () => { + expect(search("head")).toEqual([health]); + expect(search("ead")).toEqual([]); + }); + + test("matches a status, its start, or its class", () => { + expect(search("404")).toEqual([github]); + expect(search("50")).toEqual([health]); + expect(search("2xx")).toEqual([stripe, logo]); + }); + + test("matches the content type", () => { + expect(search("png")).toEqual([logo]); + expect(search("json")).toEqual([stripe, github]); + }); + + test("needs every term to match", () => { + expect(search("json get")).toEqual([github]); + expect(search(" json 2xx ")).toEqual([stripe]); + expect(search("json 5xx")).toEqual([]); + expect(search("")).toEqual(all); + }); + + test("keeps flows under a host or path, as the sidebar places them", () => { + const under = (node: string, url: string) => matchesQuery(flow(url), { under: [node] }); + expect(under("api.stripe.com", "https://api.stripe.com/v1/charges")).toBe(true); + expect(under("api.stripe.com", "http://api.stripe.com")).toBe(true); + // Another port is a host node of its own, as in the sidebar. + expect(under("api.stripe.com", "https://api.stripe.com:8443/v1")).toBe(false); + // Empty segments and default ports are left out, as the sidebar leaves them. + expect(under("example.com/v1/users", "https://example.com/v1//users")).toBe(true); + expect(under("x.com/a", "http://x.com:80/a")).toBe(true); + expect(under("api.stripe.com", "https://api.stripe.community/")).toBe(false); + expect(under("api.stripe.com/v1/charges", "https://api.stripe.com/v1/charges/ch_1?x")).toBe( + true, + ); + expect(under("api.stripe.com/v1/charges", "https://api.stripe.com/v1/chargesx")).toBe(false); + expect(under("api.stripe.com:8443", "https://api.stripe.com/v1")).toBe(false); + // The sidebar lowercases hosts; paths keep their case. + expect(under("example.com/A", "http://EXAMPLE.com/A/b")).toBe(true); + expect(under("example.com/a", "http://example.com/A")).toBe(false); + }); + + test("keeps flows from an app, by bundle id or path", () => { + const chrome = flow("https://example.com/", { + clientBundleId: "com.google.Chrome", + clientPath: "/Applications/Google Chrome.app", + }); + const curl = flow("https://example.com/", { clientPath: "/usr/bin/curl" }); + const unknown = flow("https://example.com/"); + const from = (app: string[]) => [chrome, curl, unknown].filter((f) => matchesQuery(f, { app })); + expect(from(["com.google.Chrome"])).toEqual([chrome]); + expect(from(["/usr/bin/curl"])).toEqual([curl]); + expect(from(["/Applications/Google Chrome.app"])).toEqual([]); + }); + + test("combines terms with the sidebar's selection", () => { + expect(matchesQuery(stripe, { q: "post", under: ["api.stripe.com"] })).toBe(true); + expect(matchesQuery(stripe, { q: "post", under: ["api.github.com"] })).toBe(false); + }); +}); + +describe("sidebarPath", () => { + // The cases `sidebar_path_rules` in `flows.rs` checks, so the two agree. + test("is the lowercased host without its default port, then the non-empty segments", () => { + for (const [url, path] of [ + ["https://example.com/v1//users", "example.com/v1/users"], + ["http://x.com:80/a", "x.com/a"], + ["https://x.com:443/a?q=1#f", "x.com/a"], + ["http://x.com:443/a", "x.com:443/a"], + ["https://API.Stripe.com:8443/", "api.stripe.com:8443"], + ["https://api.stripe.com", "api.stripe.com"], + ["https://api.stripe.com?x=/y", "api.stripe.com"], + ["http://[::1]:80/a/B", "[::1]/a/B"], + ["/api", "/api"], + ]) { + expect(sidebarPath(url ?? ""), url).toBe(path); + } + }); +}); + +describe("selectionQuery", () => { + test("is null for nothing selected or a node holding every flow", () => { + expect(selectionQuery([])).toBeNull(); + expect(selectionQuery(["root"])).toBeNull(); + expect(selectionQuery(["domain:a.com", "domains"])).toBeNull(); + expect(selectionQuery(["apps"])).toBeNull(); + }); + + test("splits hosts and paths from apps", () => { + expect(selectionQuery(["domain:a.com", "path:b.com/v1", "app:com.apple.Safari"])).toEqual({ + under: ["a.com", "b.com/v1"], + app: ["com.apple.Safari"], + }); + expect(selectionQuery(["app:/usr/bin/curl"])).toEqual({ app: ["/usr/bin/curl"] }); + }); +}); + +describe("searchTerms", () => { + test("trims and collapses the box's text, or is null without any", () => { + expect(searchTerms(" stripe post ")).toBe("stripe post"); + expect(searchTerms(" ")).toBeNull(); + }); +}); diff --git a/apps/yaak-proxy/lib/search.ts b/apps/yaak-proxy/lib/search.ts new file mode 100644 index 000000000..4260b58c9 --- /dev/null +++ b/apps/yaak-proxy/lib/search.ts @@ -0,0 +1,116 @@ +import type { Flow, FlowFilter } from "@yaakapp-internal/proxy-lib"; + +/** + * A search over every flow in the database: the search box's terms and the sidebar's selection, + * as `list_flows` takes them. + */ +export type FlowQuery = Pick; + +/** The sidebar node for all of an app's flows */ +export function appNodeId(key: string): string { + return `app:${key}`; +} + +/** What groups an app's flows: its bundle id, or its path for a process outside an app */ +export function appKeyOf(flow: Flow): string | null { + return flow.clientBundleId ?? flow.clientPath; +} + +/** + * The sidebar's selection as a query: hosts and paths under `under`, apps under `app`. Null when + * the selection holds every flow. + */ +export function selectionQuery(selectedIds: string[]): FlowQuery | null { + const under: string[] = []; + const app: string[] = []; + for (const id of selectedIds) { + if (id === "root" || id === "domains" || id === "apps") return null; + if (id.startsWith("domain:")) under.push(id.slice("domain:".length)); + else if (id.startsWith("path:")) under.push(id.slice("path:".length)); + else if (id.startsWith("app:")) app.push(id.slice("app:".length)); + } + if (under.length === 0 && app.length === 0) return null; + return { + ...(under.length > 0 ? { under } : {}), + ...(app.length > 0 ? { app } : {}), + }; +} + +/** + * Whether `flow` is one `query` covers, as `list_flows` decides it in SQL, so new traffic joins a + * search as it arrives. Every term must match the URL, the method, the status or the content type. + */ +export function matchesQuery(flow: Flow, query: FlowQuery): boolean { + for (const term of (query.q ?? "").toLowerCase().split(/\s+/)) { + if (term === "") continue; + const matched = + flow.url.toLowerCase().includes(term) || + flow.method.toLowerCase() === term || + (flow.contentType?.toLowerCase().includes(term) ?? false) || + matchesStatus(flow.status, term); + if (!matched) return false; + } + if (query.under != null && !query.under.some((node) => isUnder(flow.url, node))) return false; + if (query.app != null) { + const key = appKeyOf(flow); + if (key == null || !query.app.includes(key)) return false; + } + return true; +} + +/** `404` exactly, `40` as the start of one, or a class such as `4xx` */ +function matchesStatus(status: number | null, term: string): boolean { + if (status == null) return false; + const klass = /^([1-9])xx$/.exec(term); + if (klass != null) return Math.trunc(status / 100) === Number(klass[1]); + if (!/^\d{1,3}$/.test(term)) return false; + return term.length === 3 ? status === Number(term) : String(status).startsWith(term); +} + +/** The URL is the host or path `node` names, or something below it, by the sidebar's rules */ +function isUnder(url: string, node: string): boolean { + const path = sidebarPath(url); + const want = normalizeNode(node); + return path === want || path.startsWith(`${want}/`); +} + +/** + * Where the sidebar places a URL: its host, lowercased and without its scheme's default port, + * then its path's non-empty segments, joined by `/`, as in `api.stripe.com/v1/charges`. A URL + * without a scheme is a host of its own. `sidebar_path` in `crates-proxy/yaak-proxy-lib`, which + * the database filters by, follows the same rules. + */ +export function sidebarPath(url: string): string { + const schemeEnd = url.indexOf("://"); + if (schemeEnd === -1) return url; + const scheme = url.slice(0, schemeEnd).toLowerCase(); + const rest = url.slice(schemeEnd + 3); + const authorityEnd = rest.search(/[/?#]/); + let host = (authorityEnd === -1 ? rest : rest.slice(0, authorityEnd)).toLowerCase(); + const defaultPort = + scheme === "http" || scheme === "ws" + ? ":80" + : scheme === "https" || scheme === "wss" + ? ":443" + : null; + if (defaultPort != null && host.endsWith(defaultPort)) { + host = host.slice(0, -defaultPort.length); + } + const path = authorityEnd === -1 ? "" : (rest.slice(authorityEnd).split(/[?#]/)[0] ?? ""); + const segments = path.split("/").filter((s) => s !== ""); + return [host, ...segments].join("/"); +} + +/** A sidebar node as `sidebarPath` writes it, its host lowercased */ +function normalizeNode(node: string): string { + const slash = node.indexOf("/"); + return slash === -1 + ? node.toLowerCase() + : `${node.slice(0, slash).toLowerCase()}${node.slice(slash)}`; +} + +/** The search box's text as terms for `list_flows`, or null when it has none */ +export function searchTerms(text: string): string | null { + const q = text.trim().replace(/\s+/g, " "); + return q === "" ? null : q; +} diff --git a/apps/yaak-proxy/lib/sidebarCollapse.test.ts b/apps/yaak-proxy/lib/sidebarCollapse.test.ts new file mode 100644 index 000000000..61072307a --- /dev/null +++ b/apps/yaak-proxy/lib/sidebarCollapse.test.ts @@ -0,0 +1,75 @@ +import type { TreeNode } from "@yaakapp-internal/ui"; +import { describe, expect, test } from "vite-plus/test"; +import { applyCollapsed, collapsedMap, folderIds } from "./sidebarCollapse"; + +/** A node with a children list, empty or not, as the sidebar builds every node */ +function node(id: string, children: TreeNode<{ id: string }>[] = []): TreeNode<{ id: string }> { + return { item: { id }, parent: null, depth: 0, children }; +} + +const tree = node("root", [ + node("domains", [ + node("domain:api.example.com", [ + node("path:api.example.com/v1", [node("path:api.example.com/v1/users")]), + ]), + node("domain:cdn.example.com", [node("path:cdn.example.com/app.js")]), + node("domain:empty.example.com"), + ]), +]); + +describe("collapsedMap", () => { + test("collapses every domain and path, and leaves the root and Domains open", () => { + const map = collapsedMap(folderIds(tree), {}, {}); + // Empty ones too: the tree collapses one before the left arrow moves to its parent. + expect(map).toEqual({ + "domain:api.example.com": true, + "path:api.example.com/v1": true, + "path:api.example.com/v1/users": true, + "domain:cdn.example.com": true, + "path:cdn.example.com/app.js": true, + "domain:empty.example.com": true, + }); + expect(map.root).toBeUndefined(); + expect(map.domains).toBeUndefined(); + }); + + test("opens what the user expanded, and a host seen later starts collapsed", () => { + const expanded = { "domain:api.example.com": true }; + expect(collapsedMap(folderIds(tree), expanded, {})).toMatchObject({ + "domain:api.example.com": false, + "path:api.example.com/v1": true, + "domain:cdn.example.com": true, + }); + const later = node("root", [ + node("domains", [node("domain:new.example.com", [node("path:new.example.com/x")])]), + ]); + expect(collapsedMap(folderIds(later), expanded, {})["domain:new.example.com"]).toBe(true); + }); +}); + +describe("applyCollapsed", () => { + test("keeps an expanded domain, and forgets it when collapsed again", () => { + const prev = collapsedMap(folderIds(tree), {}, {}); + const opened = applyCollapsed(prev, { ...prev, "domain:api.example.com": false }, {}, {}); + expect(opened.expanded).toEqual({ "domain:api.example.com": true }); + + // Persisted, so the next launch builds the map from it. + const relaunched = collapsedMap(folderIds(tree), opened.expanded, {}); + expect(relaunched["domain:api.example.com"]).toBe(false); + + const closed = applyCollapsed( + relaunched, + { ...relaunched, "domain:api.example.com": true }, + opened.expanded, + {}, + ); + expect(closed.expanded).toEqual({}); + }); + + test("keeps the other nodes' flags apart from the persisted ones", () => { + const prev = collapsedMap(folderIds(tree), {}, {}); + const next = applyCollapsed(prev, { ...prev, domains: true }, {}, {}); + expect(next).toEqual({ expanded: {}, others: { domains: true } }); + expect(collapsedMap(folderIds(tree), next.expanded, next.others).domains).toBe(true); + }); +}); diff --git a/apps/yaak-proxy/lib/sidebarCollapse.ts b/apps/yaak-proxy/lib/sidebarCollapse.ts new file mode 100644 index 000000000..648bc42e8 --- /dev/null +++ b/apps/yaak-proxy/lib/sidebarCollapse.ts @@ -0,0 +1,62 @@ +import type { TreeNode } from "@yaakapp-internal/ui"; + +/** Flags by node id, as the tree reads them: true is collapsed, and a missing id is open */ +export type CollapsedMap = Record; + +/** Domains and paths start collapsed; the rest, the Domains folder, starts open as before */ +export function startsCollapsed(id: string): boolean { + return id.startsWith("domain:") || id.startsWith("path:"); +} + +/** + * Ids of the nodes the tree treats as folders, those with a children list, empty or not. The + * tree collapses an empty one before moving to its parent on the left arrow, so it needs a flag. + */ +export function folderIds(root: TreeNode<{ id: string }>): string[] { + const ids: string[] = []; + const walk = (node: TreeNode<{ id: string }>) => { + if (node.children == null) return; + ids.push(node.item.id); + for (const child of node.children ?? []) walk(child); + }; + walk(root); + return ids; +} + +/** + * The tree's collapsed flags: every domain and path collapsed unless the user expanded it, and + * other nodes as the user left them. + */ +export function collapsedMap( + folders: string[], + expanded: Record, + others: CollapsedMap, +): CollapsedMap { + const map = { ...others }; + for (const id of folders) { + if (startsCollapsed(id)) map[id] = !expanded[id]; + } + return map; +} + +/** + * Splits the flags the tree sets into the domains and paths now expanded, which are kept, and + * the other nodes' flags. Only the nodes whose flag changed from `prev` count. + */ +export function applyCollapsed( + prev: CollapsedMap, + next: CollapsedMap, + expanded: Record, + others: CollapsedMap, +): { expanded: Record; others: CollapsedMap } { + const nextExpanded = { ...expanded }; + const nextOthers = { ...others }; + for (const id of new Set([...Object.keys(prev), ...Object.keys(next)])) { + const collapsed = next[id] === true; + if (collapsed === (prev[id] === true)) continue; + if (!startsCollapsed(id)) nextOthers[id] = collapsed; + else if (collapsed) delete nextExpanded[id]; + else nextExpanded[id] = true; + } + return { expanded: nextExpanded, others: nextOthers }; +} diff --git a/apps/yaak-proxy/lib/statusReason.ts b/apps/yaak-proxy/lib/statusReason.ts new file mode 100644 index 000000000..fd306426c --- /dev/null +++ b/apps/yaak-proxy/lib/statusReason.ts @@ -0,0 +1,41 @@ +const reasons: Record = { + 100: "Continue", + 101: "Switching Protocols", + 200: "OK", + 201: "Created", + 202: "Accepted", + 204: "No Content", + 206: "Partial Content", + 301: "Moved Permanently", + 302: "Found", + 303: "See Other", + 304: "Not Modified", + 307: "Temporary Redirect", + 308: "Permanent Redirect", + 400: "Bad Request", + 401: "Unauthorized", + 402: "Payment Required", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 406: "Not Acceptable", + 408: "Request Timeout", + 409: "Conflict", + 410: "Gone", + 412: "Precondition Failed", + 413: "Content Too Large", + 415: "Unsupported Media Type", + 418: "I'm a teapot", + 422: "Unprocessable Content", + 429: "Too Many Requests", + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", +}; + +/** The engine records the status code only, so the reason is the standard one */ +export function statusReason(status: number): string | null { + return reasons[status] ?? null; +} diff --git a/apps/yaak-proxy/lib/store.ts b/apps/yaak-proxy/lib/store.ts index fea413cf7..90e8d25d1 100644 --- a/apps/yaak-proxy/lib/store.ts +++ b/apps/yaak-proxy/lib/store.ts @@ -1,11 +1,356 @@ -import { createModelStore } from "@yaakapp-internal/model-store"; -import type { HttpExchange } from "@yaakapp-internal/proxy-lib"; +import type { GetProxyStateResponse, ModelPayload } from "@yaakapp-internal/proxy-lib"; +import { selectedIdsFamily } from "@yaakapp-internal/ui"; +import { atom, type createStore } from "jotai"; +import { followingAtom, SIDEBAR_TREE_ID, selectedFlowAtom } from "./atoms"; +import { + applyChanges, + emptyWindow, + type FlowWindow, + PAGE_SIZE, + prependOlder, + trimWindow, + WINDOW_SIZE, + windowFromPage, +} from "./flowWindow"; +import { listen, rpc } from "./rpc"; +import { type FlowQuery, matchesQuery, selectionQuery } from "./search"; -type ProxyModels = { - http_exchange: HttpExchange; -}; +type Store = ReturnType; -export const { dataAtom, applyChange, replaceAll, listAtom, orderedListAtom } = - createModelStore(["http_exchange"]); +/** How long new flows gather before they reach the list, so a burst renders once */ +const FLUSH_MS = 100; +/** How long after new flows the total is counted again from the database */ +const RECOUNT_MS = 1000; +/** How often the database is read for new flows while another process owns the engine */ +const POLL_MS = 1000; +/** How often the engine's owner is checked while it is another process */ +const OWNER_POLL_MS = 2000; -export const httpExchangesAtom = orderedListAtom("http_exchange", "createdAt", "desc"); +/** The flows in memory: a window onto the database, which keeps them all */ +export const flowWindowAtom = atom(emptyWindow); + +/** Oldest first, so new traffic arrives at the end of the list */ +export const flowsAtom = atom((get) => get(flowWindowAtom).flows); + +/** Start of the newest flow held, across all traffic */ +export const newestStartedAtAtom = atom((get) => get(flowsAtom).at(-1)?.startedAt ?? null); + +/** Flows in the database */ +export const flowTotalAtom = atom((get) => get(flowWindowAtom).total); + +/** The first page of flows has loaded, so an empty window means an empty database */ +export const flowsLoadedAtom = atom(false); + +/** + * The database has no flows and nothing narrows the list, so the first-run card shows. A boolean + * of its own, so new traffic does not re-render what reads it. + */ +export const showFirstRunAtom = atom( + (get) => get(flowsLoadedAtom) && get(flowTotalAtom) === 0 && get(activeQueryAtom) == null, +); + +/** Some flow held names the app that sent it, which only some platforms can tell */ +export const hasAppsAtom = atom((get) => get(flowsAtom).some((f) => f.clientName != null)); + +/** The search box's terms, once typing settles, or null */ +export const searchTermsAtom = atom(null); + +/** The search the list shows, from the search box and the sidebar, or null for all traffic */ +export const activeQueryAtom = atom((get) => { + const q = get(searchTermsAtom); + const selection = selectionQuery(get(selectedIdsFamily(SIDEBAR_TREE_ID))); + if (q == null && selection == null) return null; + return { ...(q == null ? {} : { q }), ...selection }; +}); + +/** A window onto the flows a search covers, loaded from the database as the main one is */ +export interface SearchWindow { + key: string; + query: FlowQuery; + win: FlowWindow; + /** The first page has not arrived yet */ + loading: boolean; + /** Changes that came while the first page loaded, applied once it is in */ + buffered: ModelPayload[]; +} + +export const searchWindowAtom = atom(null); + +/** The window the list shows: the search's, or all traffic's */ +const shownWindowAtom = atom((get) => get(searchWindowAtom)?.win ?? get(flowWindowAtom)); + +/** The flows the list shows, oldest first */ +export const shownFlowsAtom = atom((get) => get(shownWindowAtom).flows); + +/** Flows in the database the list covers: every one, or those the search matches */ +export const shownTotalAtom = atom((get) => get(shownWindowAtom).total); + +/** Start and id of the oldest flow shown, which loading older flows moves back */ +export const oldestFlowAtom = atom((get) => get(shownFlowsAtom)[0] ?? null); + +/** Follows new traffic, dropping the older flows scrolling up loaded, or holds the list */ +export const followAtom = atom( + (get) => get(followingAtom), + (_get, set, following: boolean) => { + set(followingAtom, following); + if (!following) return; + set(flowWindowAtom, (win) => trimWindow(win)); + set(searchWindowAtom, (s) => (s == null ? s : { ...s, win: trimWindow(s.win) })); + }, +); + +/** The engine's state, kept current from its events, and polled while another process has it */ +export const proxyStateAtom = atom(null); + +/** + * Changes applied while a page of older flows loads, or null when none is loading. A slow flow + * that ends meanwhile may be older than the window, so left out of it, and missing from a page + * read before it was written; applying these again once the page is in keeps it. + */ +let changesDuringPage: ModelPayload[] | null = null; + +/** Loads the page of flows before the oldest one shown, from the search's flows when searching */ +export const loadOlderFlowsAtom = atom(null, async (get, set) => { + const search = get(searchWindowAtom); + const { flows, hasOlder } = search?.win ?? get(flowWindowAtom); + const first = flows[0]; + if (changesDuringPage != null || !hasOlder || first == null) return; + const changes: ModelPayload[] = []; + changesDuringPage = changes; + try { + const res = await rpc("list_flows", { ...search?.query, before: first.id, limit: PAGE_SIZE }); + // Dropped when the window moved on meanwhile, trimmed, cleared or searched again. + const merge = (win: FlowWindow) => { + if (win.flows[0]?.id !== first.id) return win; + const merged = prependOlder(win, res.flows, PAGE_SIZE); + const kept = search == null ? changes : changesFor(search.query, changes); + // Counted when they first applied. + return { ...applyChanges(merged, kept), total: merged.total }; + }; + if (search == null) { + set(flowWindowAtom, merge); + } else { + set(searchWindowAtom, (s) => (s?.key === search.key ? { ...s, win: merge(s.win) } : s)); + } + } catch (err) { + console.warn("Failed to load older flows", err); + } finally { + changesDuringPage = null; + } +}); + +/** The changes a search's window takes: flows it matches, and every deletion */ +function changesFor(query: FlowQuery, changes: ModelPayload[]): ModelPayload[] { + return changes.filter((c) => c.change.type === "delete" || matchesQuery(c.model, query)); +} + +/** + * Loads the newest flows, then keeps them current with the engine's events, or with reads of the + * database while another process owns the engine. Events gather for `FLUSH_MS` and apply + * together, and the window drops its oldest flows while the list follows, so a new flow costs the + * same however many the database holds. A search gets a window of its own, kept current the same + * way. + */ +export function syncFlows(store: Store) { + let pending: ModelPayload[] = []; + let timer: ReturnType | null = null; + let loaded = false; + let loads = 0; + let searchLoads = 0; + /** A flow written at or before the newest the app has seen, for reading new ones after it */ + let seenId: string | null = null; + + function flush() { + timer = null; + if (!loaded || pending.length === 0) return; + const changes = pending; + pending = []; + changesDuringPage?.push(...changes); + const following = store.get(followingAtom); + store.set(flowWindowAtom, (win) => { + const next = applyChanges(win, changes); + return following ? trimWindow(next) : next; + }); + store.set(searchWindowAtom, (s) => { + if (s == null) return s; + // A matching flow written after the page's snapshot would otherwise be missed. + if (s.loading) return { ...s, buffered: [...s.buffered, ...changes] }; + const next = applyChanges(s.win, changesFor(s.query, changes)); + return { ...s, win: following ? trimWindow(next) : next }; + }); + const selected = store.get(selectedFlowAtom); + const change = changes.findLast((c) => c.model.id === selected?.id); + if (change != null) { + store.set(selectedFlowAtom, change.change.type === "delete" ? null : change.model); + } + if (recountTimer == null) recountTimer = setTimeout(recount, RECOUNT_MS); + } + + function receive(changes: ModelPayload[]) { + pending.push(...changes); + if (timer == null) timer = setTimeout(flush, FLUSH_MS); + } + + // The total counts each new flow as it comes, which a flow both in a page and in the events + // around it can count twice or not at all; a count from the database a moment later settles it. + let recountTimer: ReturnType | null = null; + function recount() { + recountTimer = null; + const thisLoad = loads; + void rpc("list_flows", { limit: 0 }).then((res) => { + if (thisLoad !== loads) return; + store.set(flowWindowAtom, (win) => + win.total === res.total ? win : { ...win, total: res.total }, + ); + }); + const search = store.get(searchWindowAtom); + if (search == null || search.loading) return; + void rpc("list_flows", { ...search.query, limit: 0 }).then((res) => { + const total = res.matching ?? res.total; + store.set(searchWindowAtom, (s) => + s?.key !== search.key || s.win.total === total ? s : { ...s, win: { ...s.win, total } }, + ); + }); + } + + listen("model_write", (payload) => { + seenId = payload.model.id; + receive([payload]); + }); + + // Loads again rather than only emptying, for a flow written between the delete and the event. + listen("flows_cleared", () => { + pending = []; + store.set(flowWindowAtom, emptyWindow); + store.set(selectedFlowAtom, null); + store.set(followingAtom, true); + load(); + }); + + function load() { + loaded = false; + const thisLoad = ++loads; + void rpc("list_flows", { limit: WINDOW_SIZE }).then((res) => { + // A clear since asked for a newer page. + if (thisLoad !== loads) return; + store.set(flowWindowAtom, windowFromPage(res.flows, WINDOW_SIZE, res.total)); + seenId = res.flows[0]?.id ?? null; + loaded = true; + store.set(flowsLoadedAtom, true); + flush(); + }); + loadSearch(store.get(activeQueryAtom)); + } + + /** Loads the first page of `query`'s flows into a window of its own, or drops it for null */ + function loadSearch(query: FlowQuery | null) { + if (query == null) { + store.set(searchWindowAtom, null); + return; + } + // Unique to this load, so a slower answer to an earlier one with the same query, such as + // one read before a clear, is dropped. + const key = `${++searchLoads}:${JSON.stringify(query)}`; + store.set(searchWindowAtom, { key, query, win: emptyWindow, loading: true, buffered: [] }); + const settle = (page: FlowWindow | null) => + store.set(searchWindowAtom, (s) => { + if (s?.key !== key) return s; + // Applied again over the page: a flow already in it is replaced, not added. + const win = applyChanges(page ?? s.win, changesFor(query, s.buffered)); + return { + ...s, + win: page == null ? win : { ...win, total: page.total }, + loading: false, + buffered: [], + }; + }); + void rpc("list_flows", { ...query, limit: WINDOW_SIZE }) + .then((res) => settle(windowFromPage(res.flows, WINDOW_SIZE, res.matching ?? res.total))) + .catch((err) => { + console.warn("Failed to search flows", err); + settle(null); + }); + } + + let activeKey = JSON.stringify(store.get(activeQueryAtom)); + store.sub(activeQueryAtom, () => { + const query = store.get(activeQueryAtom); + const key = JSON.stringify(query); + if (key === activeKey) return; + activeKey = key; + loadSearch(query); + }); + + // While another process owns the engine no events come, so new flows are read from the + // database. Reading after a flow that started last can return a slow one already seen, which + // applies as an update; it never skips one. + let polling = false; + function readNewFlows() { + if (polling || !loaded) return; + polling = true; + const after = seenId; + const read = + after == null + ? rpc("list_flows", { limit: WINDOW_SIZE }) + : rpc("list_flows", { after, limit: WINDOW_SIZE }); + read + .then((res) => { + if (res.flows.length === 0) return; + // More arrived than one read holds: load the newest again rather than leave a gap. + if (res.flows.length >= WINDOW_SIZE) { + load(); + return; + } + if (seenId === after) seenId = res.flows[0]?.id ?? seenId; + const known = new Set(store.get(flowsAtom).map((f) => f.id)); + receive( + [...res.flows].reverse().map((model) => ({ + model, + change: { type: "upsert", created: !known.has(model.id) }, + })), + ); + }) + // The flow read after was cleared: start again from the newest. + .catch(() => { + seenId = null; + load(); + }) + .finally(() => { + polling = false; + }); + } + setInterval(() => { + if (store.get(proxyStateAtom)?.otherOwner != null) readNewFlows(); + }, POLL_MS); + // One last read once another owner lets go, for flows it wrote after the last one. + let hadOtherOwner = store.get(proxyStateAtom)?.otherOwner != null; + store.sub(proxyStateAtom, () => { + const hasOtherOwner = store.get(proxyStateAtom)?.otherOwner != null; + if (hadOtherOwner && !hasOtherOwner) readNewFlows(); + hadOtherOwner = hasOtherOwner; + }); + + load(); +} + +/** + * Keeps `proxyStateAtom` current: on each state change, and polled while the engine is not + * running here, so another process taking it or letting it go shows. + */ +export function syncProxyState(store: Store) { + function refresh() { + rpc("get_proxy_state", {}) + .then((state) => store.set(proxyStateAtom, state)) + .catch((err) => console.warn("Failed to read the proxy state", err)); + } + listen("proxy_state_changed", refresh); + setInterval(() => { + if (store.get(proxyStateAtom)?.state !== "running") refresh(); + }, OWNER_POLL_MS); + refresh(); +} + +/** Reads the proxy state again, after an action that changes it without an event */ +export async function refreshProxyState(store: Store) { + store.set(proxyStateAtom, await rpc("get_proxy_state", {})); +} diff --git a/apps/yaak-proxy/lib/systemProxy.test.ts b/apps/yaak-proxy/lib/systemProxy.test.ts new file mode 100644 index 000000000..c20437f6d --- /dev/null +++ b/apps/yaak-proxy/lib/systemProxy.test.ts @@ -0,0 +1,161 @@ +import type { HelperState } from "@yaakapp-internal/proxy-lib"; +import { describe, expect, test } from "vite-plus/test"; +import { + helperPollInterval, + helperRow, + recordingLabel, + type SystemProxyState, + systemProxySwitch, +} from "./systemProxy"; + +const off: SystemProxyState = { routing: false, port: 9090, pending: null, error: null }; + +describe("systemProxySwitch", () => { + test("says whether this Mac's traffic is routed now", () => { + expect(systemProxySwitch({ ...off, routing: true })).toEqual({ + checked: true, + description: "Routing this Mac's web traffic through Yaak Proxy", + failed: false, + }); + expect(systemProxySwitch(off)).toEqual({ + checked: false, + description: "Not routing. Only apps pointed at port 9090 are captured", + failed: false, + }); + }); + + test("shows the new value while the password prompt is up", () => { + expect(systemProxySwitch({ ...off, pending: true })).toEqual({ + checked: true, + description: "Waiting for your password", + failed: false, + }); + }); + + test("waits for no password when the helper makes the change", () => { + expect(systemProxySwitch({ ...off, pending: false, helper: true })).toEqual({ + checked: false, + description: "Changing the system proxy", + failed: false, + }); + }); + + test("snaps back on a cancelled prompt and says so", () => { + expect(systemProxySwitch({ ...off, error: "Cancelled in the password dialog" })).toEqual({ + checked: false, + description: "Cancelled in the password prompt", + failed: true, + }); + expect(systemProxySwitch({ ...off, error: "networksetup failed" }).description).toBe( + "networksetup failed", + ); + }); +}); + +describe("helperRow", () => { + test("offers each next step, and says which way changes go", () => { + expect(helperRow("not_registered", "admin_prompt")).toMatchObject({ + title: "Helper: not installed", + action: "install", + }); + expect(helperRow("requires_approval", "admin_prompt")).toMatchObject({ + title: "Helper: awaiting approval", + action: "approve", + }); + const enabled = helperRow("enabled", "helper"); + expect(enabled).toMatchObject({ title: "Helper: enabled", action: "unregister" }); + expect(enabled.description).toMatch(/No password/); + }); + + test("says when the password prompt still has settings to put back", () => { + expect(helperRow("enabled", "admin_prompt").description).toMatch(/password prompt/); + }); + + test("offers to install when macOS can't find it, rather than blaming a dev build", () => { + expect(helperRow("not_found", "admin_prompt")).toMatchObject({ + title: "Helper: not found by macOS", + action: "install", + }); + }); + + test("keeps every row short", () => { + const states: HelperState[] = [ + "unavailable", + "not_found", + "not_registered", + "requires_approval", + "enabled", + ]; + for (const state of states) { + for (const backend of ["helper", "admin_prompt"] as const) { + const row = helperRow(state, backend); + expect(row.title.length).toBeLessThanOrEqual(30); + expect(row.description.length).toBeLessThanOrEqual(60); + } + } + }); + + test("has a titled row for every state the backend sends", () => { + // A missing key here fails the typecheck, so a new state can't be left out. + const states: Record = { + unavailable: true, + not_found: true, + not_registered: true, + requires_approval: true, + enabled: true, + }; + for (const state of Object.keys(states) as HelperState[]) { + for (const backend of ["helper", "admin_prompt", "sudo"] as const) { + expect(helperRow(state, backend).title).toMatch(/^Helper: \S/); + } + } + }); + + test("still gives a row for a state this build doesn't know", () => { + const row = helperRow("from_the_future" as HelperState, "admin_prompt"); + expect(row).toEqual({ + title: "Helper: from_the_future", + description: "Changes use the password prompt.", + action: null, + }); + }); + + test("says plainly that a dev build cannot use it", () => { + expect(helperRow("unavailable", "admin_prompt")).toMatchObject({ + title: "Helper: not in dev builds", + action: null, + }); + }); +}); + +describe("helperPollInterval", () => { + const now = 100_000; + + test("polls every 2 seconds while approval is pending", () => { + expect(helperPollInterval("requires_approval", null, now)).toBe(2000); + }); + + test("stops once enabled or not registered, even just after registering", () => { + expect(helperPollInterval("enabled", now - 500, now)).toBe(false); + expect(helperPollInterval("not_registered", now - 500, now)).toBe(false); + }); + + test("polls briefly after registering, until approval shows", () => { + expect(helperPollInterval("not_found", now - 500, now)).toBe(2000); + expect(helperPollInterval(undefined, now - 500, now)).toBe(2000); + expect(helperPollInterval("not_found", now - 10_000, now)).toBe(false); + }); + + test("never polls in other states", () => { + for (const helper of ["unavailable", "not_found", undefined] as const) { + expect(helperPollInterval(helper, null, now)).toBe(false); + } + }); +}); + +describe("recordingLabel", () => { + test("offers the opposite of the current state", () => { + expect(recordingLabel(true)).toBe("Pause Recording"); + expect(recordingLabel(false)).toBe("Resume Recording"); + }); +}); diff --git a/apps/yaak-proxy/lib/systemProxy.ts b/apps/yaak-proxy/lib/systemProxy.ts new file mode 100644 index 000000000..05a8c8093 --- /dev/null +++ b/apps/yaak-proxy/lib/systemProxy.ts @@ -0,0 +1,135 @@ +import type { HelperState, SystemProxyBackend } from "@yaakapp-internal/proxy-lib"; + +/** + * "Override system proxy": this Mac's web traffic pointed at the running engine, changed at once + * whenever it is flipped, through the privileged helper or the macOS password prompt. Whether it + * turns on as the app launches is a separate setting. + */ +export interface SystemProxyState { + /** This Mac's web traffic points at the engine now */ + routing: boolean; + /** The port apps can be pointed at by hand */ + port: number | null; + /** A change is in flight, to this value */ + pending: boolean | null; + /** The last change failed, with this message */ + error: string | null; + /** Changes go through the helper, with no password prompt */ + helper?: boolean; +} + +export interface SystemProxySwitch { + checked: boolean; + description: string; + /** The description reports a failure */ + failed: boolean; +} + +/** What the system proxy switch shows */ +export function systemProxySwitch(state: SystemProxyState): SystemProxySwitch { + if (state.pending != null) { + const description = state.helper ? "Changing the system proxy" : "Waiting for your password"; + return { checked: state.pending, description, failed: false }; + } + // A failed change leaves the switch where the system proxy actually is. + const checked = state.routing; + if (state.error != null) { + const cancelled = /cancel/i.test(state.error); + return { + checked, + description: cancelled ? "Cancelled in the password prompt" : state.error, + failed: true, + }; + } + return { + checked, + description: checked + ? "Routing this Mac's web traffic through Yaak Proxy" + : `Not routing. Only apps pointed at port ${state.port ?? 9090} are captured`, + failed: false, + }; +} + +export interface HelperRow { + title: string; + /** Which way changes go, and why */ + description: string; + /** What the row's button does, if it has one: the first two open the approval dialog */ + action: "install" | "approve" | "unregister" | null; +} + +/** The privileged helper's row in the state pill menu, kept short */ +export function helperRow(helper: HelperState, backend: SystemProxyBackend): HelperRow { + switch (helper) { + case "unavailable": + return { + title: "Helper: not in dev builds", + description: "Changes use the password prompt.", + action: null, + }; + case "not_found": + // What macOS reports until the helper is first installed. + return { + title: "Helper: not found by macOS", + description: "Changes use the password prompt.", + action: "install", + }; + case "not_registered": + return { + title: "Helper: not installed", + description: "Changes use the password prompt.", + action: "install", + }; + case "requires_approval": + return { + title: "Helper: awaiting approval", + description: "Changes use the password prompt.", + action: "approve", + }; + case "enabled": + return { + title: "Helper: enabled", + description: + backend === "helper" + ? "No password prompts." + : "One more password prompt puts back your settings.", + action: "unregister", + }; + default: { + // Every state is handled above; one this build doesn't know still gets a row, rather + // than taking the whole menu down. + const unknown: never = helper; + return { + title: `Helper: ${String(unknown)}`, + description: "Changes use the password prompt.", + action: null, + }; + } + } +} + +/** How often the helper's state is read while it can change outside the app */ +export const HELPER_POLL_MS = 2000; + +/** How long after registering the state is read even before it shows approval pending */ +export const HELPER_REGISTER_POLL_MS = 10_000; + +/** + * The interval to read the helper's state at: while it awaits approval in System Settings, and + * briefly after registering, until it settles as enabled or not registered. Never otherwise. + */ +export function helperPollInterval( + helper: HelperState | undefined, + registeredAt: number | null, + now: number, +): number | false { + if (helper === "requires_approval") return HELPER_POLL_MS; + if (helper === "enabled" || helper === "not_registered") return false; + const justRegistered = registeredAt != null && now - registeredAt < HELPER_REGISTER_POLL_MS; + return justRegistered ? HELPER_POLL_MS : false; +} + +/** The menu item that pauses or resumes storing flows */ +export function recordingLabel(recording: boolean): string { + return recording ? "Pause Recording" : "Resume Recording"; +} diff --git a/apps/yaak-proxy/lib/tauri.ts b/apps/yaak-proxy/lib/tauri.ts index 063a54002..e4371eb89 100644 --- a/apps/yaak-proxy/lib/tauri.ts +++ b/apps/yaak-proxy/lib/tauri.ts @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen as tauriListen } from "@tauri-apps/api/event"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { type as tauriOsType } from "@tauri-apps/plugin-os"; +import { unsubscribeWhenReady } from "./unsubscribe"; /** Call a Tauri command. */ export function command(cmd: string, args?: Record): Promise { @@ -19,6 +20,16 @@ export function subscribe(event: string, callback: (payload: T) => void): () return () => unsub?.(); } +/** Calls `callback` each time the window regains focus. Returns an unsubscribe function. */ +export function onWindowFocus(callback: () => void): () => void { + const subscription = unsubscribeWhenReady( + getCurrentWebviewWindow().onFocusChanged( + ({ payload: focused }) => focused && subscription.active() && callback(), + ), + ); + return subscription.unsubscribe; +} + /** Show the current webview window. */ export function showWindow(): Promise { return getCurrentWebviewWindow().show(); diff --git a/apps/yaak-proxy/lib/toast.ts b/apps/yaak-proxy/lib/toast.ts new file mode 100644 index 000000000..130e05ac6 --- /dev/null +++ b/apps/yaak-proxy/lib/toast.ts @@ -0,0 +1,12 @@ +import { type ShowToastArgs, showToast } from "@yaakapp-internal/ui"; +import { useStore } from "jotai"; +import { useCallback } from "react"; + +/** Shows one of the client's toasts from this app's store, a success unless it says otherwise */ +export function useShowToast() { + const store = useStore(); + return useCallback( + (toast: ShowToastArgs) => showToast(store, { color: "success", ...toast }), + [store], + ); +} diff --git a/apps/yaak-proxy/lib/unsubscribe.test.ts b/apps/yaak-proxy/lib/unsubscribe.test.ts new file mode 100644 index 000000000..0e02c65e2 --- /dev/null +++ b/apps/yaak-proxy/lib/unsubscribe.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test, vi } from "vite-plus/test"; +import { unsubscribeWhenReady } from "./unsubscribe"; + +function pending() { + let resolve: (fn: () => void) => void = () => {}; + const promise = new Promise<() => void>((r) => (resolve = r)); + return { promise, resolve }; +} + +describe("unsubscribeWhenReady", () => { + test("unsubscribes once ready when cleanup ran first", async () => { + const { promise, resolve } = pending(); + const unsub = vi.fn(); + const subscription = unsubscribeWhenReady(promise); + subscription.unsubscribe(); + expect(subscription.active()).toBe(false); + resolve(unsub); + await promise; + await Promise.resolve(); + expect(unsub).toHaveBeenCalledTimes(1); + }); + + test("unsubscribes at once when already ready, and only once", async () => { + const unsub = vi.fn(); + const promise = Promise.resolve(unsub); + const subscription = unsubscribeWhenReady(promise); + await promise; + await Promise.resolve(); + expect(subscription.active()).toBe(true); + subscription.unsubscribe(); + subscription.unsubscribe(); + expect(unsub).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/yaak-proxy/lib/unsubscribe.ts b/apps/yaak-proxy/lib/unsubscribe.ts new file mode 100644 index 000000000..c850965f8 --- /dev/null +++ b/apps/yaak-proxy/lib/unsubscribe.ts @@ -0,0 +1,25 @@ +/** + * An unsubscribe for a subscription that is still being set up. Called before the setup + * resolves, it unsubscribes as soon as it does; `active` says whether to still deliver events. + */ +export function unsubscribeWhenReady(subscription: Promise<() => void>): { + unsubscribe: () => void; + active: () => boolean; +} { + let unsub: (() => void) | null = null; + let cancelled = false; + subscription + .then((fn) => { + if (cancelled) fn(); + else unsub = fn; + }) + .catch(console.error); + return { + unsubscribe: () => { + cancelled = true; + unsub?.(); + unsub = null; + }, + active: () => !cancelled, + }; +} diff --git a/apps/yaak-proxy/main.css b/apps/yaak-proxy/main.css index da1e53f1f..21b27e572 100644 --- a/apps/yaak-proxy/main.css +++ b/apps/yaak-proxy/main.css @@ -12,6 +12,7 @@ :root { --font-family-interface: ""; --font-family-editor: ""; + --editor-font-size: 12px; } :root { diff --git a/apps/yaak-proxy/main.tsx b/apps/yaak-proxy/main.tsx index 1e197fb0e..b34daffa3 100644 --- a/apps/yaak-proxy/main.tsx +++ b/apps/yaak-proxy/main.tsx @@ -1,31 +1,23 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createStore, Provider } from "jotai"; import { LazyMotion, MotionConfig } from "motion/react"; -import { StrictMode } from "react"; +import { HotkeyProvider } from "@yaakapp-internal/ui"; +import { type ReactNode, StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { ProxyLayout } from "./components/ProxyLayout"; -import { listen, rpc } from "./lib/rpc"; -import { initHotkeys } from "./lib/hotkeys"; -import { applyChange, dataAtom, replaceAll } from "./lib/store"; +import { useHotkeyContextValue } from "./lib/hotkeys"; +import { syncFlows, syncProxyState } from "./lib/store"; import "./main.css"; const queryClient = new QueryClient(); const jotaiStore = createStore(); -// Load initial models from the database -void rpc("list_models", {}).then((res) => { - jotaiStore.set(dataAtom, (prev) => replaceAll(prev, "http_exchange", res.httpExchanges)); -}); +syncProxyState(jotaiStore); +syncFlows(jotaiStore); -// Register hotkeys from action metadata -void initHotkeys(); - -// Subscribe to model change events from the backend -void listen("model_write", (payload) => { - jotaiStore.set(dataAtom, (prev) => - applyChange(prev, "http_exchange", payload.model, payload.change), - ); -}); +function Hotkeys({ children }: { children: ReactNode }) { + return {children}; +} const motionFeatures = () => import("framer-motion").then((mod) => mod.domAnimation); @@ -33,11 +25,13 @@ createRoot(document.getElementById("root") as HTMLElement).render( - - - - - + + + + + + + , diff --git a/apps/yaak-proxy/modules.d.ts b/apps/yaak-proxy/modules.d.ts new file mode 100644 index 000000000..1ba602b91 --- /dev/null +++ b/apps/yaak-proxy/modules.d.ts @@ -0,0 +1 @@ +declare module "vkbeautify"; diff --git a/apps/yaak-proxy/package.json b/apps/yaak-proxy/package.json index 363f18b2e..7d16d9f57 100644 --- a/apps/yaak-proxy/package.json +++ b/apps/yaak-proxy/package.json @@ -9,29 +9,31 @@ "lint": "tsc --noEmit" }, "dependencies": { - "@tanstack/react-query": "^5.90.5", - "@tauri-apps/api": "^2.11.0", - "@tauri-apps/plugin-os": "^2.3.2", - "@yaakapp-internal/model-store": "^1.0.0", + "@tanstack/react-query": "^5.105.0", + "@tanstack/react-virtual": "^3.14.24", + "@tauri-apps/api": "^2.12.4", + "@tauri-apps/plugin-dialog": "^2.8.4", + "@tauri-apps/plugin-opener": "^2.7.2", + "@tauri-apps/plugin-os": "^2.4.0", "@yaakapp-internal/proxy-lib": "^1.0.0", "@yaakapp-internal/theme": "^1.0.0", "@yaakapp-internal/ui": "^1.0.0", "classnames": "^2.5.1", "jotai": "^2.18.0", - "jotai-family": "^1.0.1", - "motion": "^12.4.7", - "react": "^19.2.0", - "react-dom": "^19.2.0" + "motion": "^14.8.0", + "react": "^19.3.1", + "react-dom": "^19.3.1", + "vkbeautify": "^0.99.3" }, "devDependencies": { - "@rolldown/plugin-babel": "^0.2.3", + "@rolldown/plugin-babel": "^0.3.0", "@types/babel__core": "^7.20.5", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", - "@vitejs/plugin-react": "^6.0.1", + "@vitejs/plugin-react": "^6.1.2", "babel-plugin-react-compiler": "^1.0.0", "typescript": "^5.8.3", - "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9", - "vite-plus": "^0.2.9" + "vite": "npm:@voidzero-dev/vite-plus-core@^1.4.0", + "vite-plus": "^1.4.0" } } diff --git a/crates-cli/yaak-cli/.claude-plugin/plugin.json b/crates-cli/yaak-cli/.claude-plugin/plugin.json new file mode 100644 index 000000000..0f056c8cc --- /dev/null +++ b/crates-cli/yaak-cli/.claude-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "yaak", + "displayName": "Yaak", + "description": "Build, save, and send API requests with the yaak CLI, with its MCP server for agents", + "author": { + "name": "Yaak", + "url": "https://yaak.app" + }, + "homepage": "https://yaak.app", + "repository": "https://github.com/mountain-loop/yaak", + "license": "MIT", + "keywords": ["api", "http", "rest", "graphql", "grpc", "openapi", "api-client"], + "mcpServers": { + "yaak": { + "command": "npx", + "args": ["-y", "@yaakapp/cli", "mcp"] + } + } +} diff --git a/crates-cli/yaak-cli/Cargo.toml b/crates-cli/yaak-cli/Cargo.toml index 8618dd7d1..46b484b3d 100644 --- a/crates-cli/yaak-cli/Cargo.toml +++ b/crates-cli/yaak-cli/Cargo.toml @@ -10,10 +10,10 @@ path = "src/main.rs" [dependencies] arboard = "3" -base64 = "0.22" +base64 = "0.23" clap = { version = "4", features = ["derive"] } console = "0.15" -dirs = "6" +dirs = "7" env_logger = "0.11" futures = "0.3" inquire = { version = "0.7", features = ["editor"] } @@ -21,9 +21,12 @@ hex = { workspace = true } include_dir = "0.7" keyring = { workspace = true, features = ["apple-native", "windows-native", "sync-secret-service"] } log = { workspace = true } -rand = "0.8" -reqwest = { workspace = true } +rand = "0.10" +reqwest = { workspace = true, features = ["form"] } rolldown = "0.1.0" +# Pinned exactly: rolldown_resolver takes `oxc_resolver = "^11"`, so cargo +# drifts it forward and breaks on 11.11 (TsconfigOptions became +# TsconfigDiscovery). This dep exists only to hold that resolution. oxc_resolver = "=11.10.0" schemars = { workspace = true } serde = { workspace = true } @@ -39,18 +42,21 @@ tokio = { workspace = true, features = [ ] } walkdir = "2" webbrowser = "1" -zip = "4" +zip = "9.0.5" yaak = { workspace = true } yaak-api = { workspace = true } +yaak-common = { workspace = true } yaak-core = { workspace = true } yaak-crypto = { workspace = true } yaak-http = { workspace = true } yaak-lifecycle = { workspace = true } +yaak-mcp = { workspace = true } yaak-models = { workspace = true } yaak-plugins = { workspace = true } yaak-templates = { workspace = true } [dev-dependencies] assert_cmd = "2" +rmcp = { version = "3.5.1", default-features = false, features = ["client"] } predicates = "3" tempfile = "3" diff --git a/crates-cli/yaak-cli/README.md b/crates-cli/yaak-cli/README.md index 32157e2da..d16ce6f47 100644 --- a/crates-cli/yaak-cli/README.md +++ b/crates-cli/yaak-cli/README.md @@ -17,6 +17,30 @@ The `yaak` CLI is primarily meant to be used by AI agents, and has the following - `--verbose` mode for extracting debug info while sending requests - The ability to send entire workspaces and folders (Supports `--parallel` and `--fail-fast`) +### Agent Setup + +Install the Yaak skill so your coding agent knows how to drive the CLI: + +```sh +yaak agent install # every agent found on this machine +npx skills add mountain-loop/yaak --skill use-yaak # or with the skills CLI +``` + +Claude Code and Codex can install it as a plugin instead. In Claude Code, run +`/plugin install yaak --marketplace mountain-loop/yaak`; the plugin also registers the +`yaak mcp` server. In Codex, run `codex plugin marketplace add mountain-loop/yaak`, +then `codex plugin add yaak@yaak`. + +Any MCP client can run the server over stdio: + +```json +{ + "mcpServers": { + "yaak": { "command": "npx", "args": ["-y", "@yaakapp/cli", "mcp"] } + } +} +``` + ### Example Prompts Use the `yaak` CLI with agents like Claude or Codex to do useful things for you. diff --git a/crates-cli/yaak-cli/plugin.json b/crates-cli/yaak-cli/plugin.json new file mode 100644 index 000000000..6817f0491 --- /dev/null +++ b/crates-cli/yaak-cli/plugin.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "yaak", + "description": "Build, save, and send API requests with the yaak CLI", + "author": { + "name": "Yaak", + "url": "https://yaak.app" + }, + "homepage": "https://yaak.app", + "repository": "https://github.com/mountain-loop/yaak", + "license": "MIT", + "keywords": ["api", "http", "rest", "graphql", "grpc", "openapi", "api-client"], + "extensions": { + "com.openai": { + "interface": { + "displayName": "Yaak", + "shortDescription": "Build, save, and send API requests with Yaak", + "longDescription": "Teaches the agent the yaak CLI: create workspaces, requests, environments, and auth, send one request or a whole suite, chain responses, and import OpenAPI, Postman, Insomnia, Bruno, or cURL. Requests are saved to Yaak, so you can open them in the desktop app.", + "developerName": "Yaak", + "category": "Developer Tools", + "capabilities": ["Read", "Write"], + "websiteURL": "https://yaak.app", + "privacyPolicyURL": "https://yaak.app/privacy", + "termsOfServiceURL": "https://yaak.app/terms", + "defaultPrompt": [ + "Create Yaak requests for every route in this API", + "Import this OpenAPI spec into a Yaak workspace", + "Send my Yaak requests against staging and report failures" + ] + } + } + } +} diff --git a/crates-cli/yaak-cli/skills/use-yaak/SKILL.md b/crates-cli/yaak-cli/skills/use-yaak/SKILL.md index 030c7a99d..c37856d17 100644 --- a/crates-cli/yaak-cli/skills/use-yaak/SKILL.md +++ b/crates-cli/yaak-cli/skills/use-yaak/SKILL.md @@ -1,19 +1,12 @@ --- name: use-yaak description: > - Build and run HTTP API requests with the Yaak CLI (`yaak`): create workspaces, - folders, environments and variables, author HTTP requests, configure - authentication (OAuth 2.0, bearer tokens, API keys, basic, JWT, AWS SigV4), - send them individually or a whole folder/workspace at once, chain one - request's response into the next, and import existing APIs from OpenAPI, - Postman, Insomnia, or cURL. Use this skill whenever the user mentions Yaak, a - Yaak workspace, or the `yaak` command, and also when they ask to try, hit, - call, exercise, or smoke test an HTTP or REST endpoint, to save or organize - API requests for reuse, to set up API requests for manual testing, to add auth - to a saved request, to turn an OpenAPI or Postman collection into runnable - requests, or to run a saved request suite against staging versus production. - Prefer this over one-off `curl` commands whenever the requests should be - saved, reused, shared, or run as a set. + Use when the user mentions Yaak, a Yaak workspace, or the `yaak` command, or + asks to call, hit, or smoke test HTTP/REST endpoints, save or organize API + requests for reuse or manual testing, configure auth on saved requests, import + an OpenAPI spec, a Postman, Insomnia, or Bruno collection, or a cURL command, or run + saved requests across environments. Prefer over one-off `curl` when requests + should be saved, reused, shared, or run as a set. allowed-tools: Bash(yaak:*), Bash(which:*), Bash(command:*), Bash(npm:*), Bash(npx:*) --- @@ -34,7 +27,7 @@ only for publishing plugins to the Yaak registry. Two consequences worth holding onto. Requests you create are permanent user data in an app they use, not scratch files, so name them the way the user would and clean up anything created just to test. And because the app is right there, the -CLI is usually the wrong place to *read* a response in detail; it is the right +CLI is usually the wrong place to _read_ a response in detail; it is the right place to build, organize, and run requests. ## The CLI describes itself @@ -105,9 +98,9 @@ re-run it after acting. ## Core workflows -**Start from a spec when one exists.** `yaak import ` auto-detects OpenAPI, -Swagger, Postman, Insomnia, cURL, and Yaak exports, and beats authoring requests -by hand every time. +**Start from a spec when one exists.** `yaak import ` auto-detects OpenAPI, +Swagger, Postman, Insomnia, Bruno, cURL, and Yaak exports, and beats authoring requests +by hand every time. Bruno collections can also be imported from a ZIP or directory. **Make the host swappable.** Put the base URL in a base-environment variable, reference it as `${[ base_url ]}`, then add a sub-environment per deployment diff --git a/crates-cli/yaak-cli/src/cli.rs b/crates-cli/yaak-cli/src/cli.rs index b4754ac62..c52ff72bf 100644 --- a/crates-cli/yaak-cli/src/cli.rs +++ b/crates-cli/yaak-cli/src/cli.rs @@ -45,7 +45,7 @@ pub enum Commands { /// Authentication commands Auth(AuthArgs), - /// Import API data from Yaak, OpenAPI, Postman, Insomnia, Swagger, or cURL + /// Import API data from Yaak, OpenAPI, Postman, Insomnia, Bruno, Swagger, or cURL Import(ImportArgs), /// Export Yaak workspace data @@ -92,6 +92,24 @@ pub enum Commands { /// Response commands Response(ResponseArgs), + + /// Serve Yaak's MCP tools to AI agents like Claude Code, over stdio or with --http + Mcp(McpArgs), +} + +#[derive(Args)] +pub struct McpArgs { + /// Serve over HTTP on 127.0.0.1 instead of stdio, for agents that cannot run this command + #[arg(long)] + pub http: bool, + + /// Port to listen on with --http + #[arg(long, default_value_t = yaak_mcp::http::DEFAULT_PORT, requires = "http")] + pub port: u16, + + /// Bearer token clients must send with --http; generated and printed when omitted + #[arg(long, requires = "http", value_name = "TOKEN")] + pub token: Option, } #[derive(Args)] @@ -202,7 +220,7 @@ pub struct SendArgs { #[derive(Args)] pub struct ImportArgs { - /// Path to the file to import + /// Path to the file, ZIP archive, or directory to import pub file: PathBuf, /// Existing workspace ID to import into when supported by the importer diff --git a/crates-cli/yaak-cli/src/commands/agent.rs b/crates-cli/yaak-cli/src/commands/agent.rs index 3bc470106..8c41d6d9f 100644 --- a/crates-cli/yaak-cli/src/commands/agent.rs +++ b/crates-cli/yaak-cli/src/commands/agent.rs @@ -184,6 +184,14 @@ fn installed_version(dir: &Path) -> Option { .filter(|v| !v.is_empty() && v != VERSION_PLACEHOLDER) } +/// A skill installed by a plugin manager or `npx skills add` keeps the unstamped +/// placeholder; that manager owns its updates, so it is not ours to call stale. +fn is_plugin_managed(dir: &Path) -> bool { + fs::read_to_string(dir.join("SKILL.md")) + .map(|text| text.contains(VERSION_PLACEHOLDER)) + .unwrap_or(false) +} + /// Health summary appended to root `--help`, so an agent can notice in one command /// that an upgraded CLI has left the installed skill behind. Silent when nothing is /// installed anywhere, to avoid nagging users who do not use agent tooling. @@ -195,7 +203,7 @@ pub fn help_section() -> Option { let mut installed = 0usize; for target in &targets { let dir = target.skills_dir.join(SKILL_NAME); - if !dir.join("SKILL.md").exists() { + if !dir.join("SKILL.md").exists() || is_plugin_managed(&dir) { continue; } installed += 1; diff --git a/crates-cli/yaak-cli/src/commands/auth.rs b/crates-cli/yaak-cli/src/commands/auth.rs index b0ece3fb9..3d7673184 100644 --- a/crates-cli/yaak-cli/src/commands/auth.rs +++ b/crates-cli/yaak-cli/src/commands/auth.rs @@ -3,8 +3,8 @@ use crate::ui; use crate::utils::http; use base64::Engine as _; use keyring::Entry; -use rand::RngCore; -use rand::rngs::OsRng; +use rand::TryRng; +use rand::rngs::SysRng; use reqwest::Url; use serde_json::Value; use sha2::{Digest, Sha256}; @@ -443,7 +443,7 @@ fn delete_auth_token(environment: Environment) -> CommandResult { fn random_hex(bytes: usize) -> String { let mut data = vec![0_u8; bytes]; - OsRng.fill_bytes(&mut data); + SysRng.try_fill_bytes(&mut data).expect("OS random source failed"); hex::encode(data) } diff --git a/crates-cli/yaak-cli/src/commands/environment.rs b/crates-cli/yaak-cli/src/commands/environment.rs index 2c060a920..856417a7a 100644 --- a/crates-cli/yaak-cli/src/commands/environment.rs +++ b/crates-cli/yaak-cli/src/commands/environment.rs @@ -49,8 +49,11 @@ fn schema(pretty: bool) -> CommandResult { fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult { let workspace_id = resolve_workspace_id(ctx, workspace_id, "environment list")?; let environments = ctx - .db() - .list_environments_ensure_base(&workspace_id) + .query_manager() + .with_tx(|tx| { + tx.ensure_base_environment(&workspace_id)?; + tx.list_environments(&workspace_id) + }) .map_err(|e| format!("Failed to list environments: {e}"))?; if environments.is_empty() { @@ -111,8 +114,8 @@ fn create( } let created = ctx - .db() - .upsert_environment(&environment, &UpdateSource::Sync) + .query_manager() + .with_tx(|tx| tx.upsert_environment(&environment, &UpdateSource::Sync)) .map_err(|e| format!("Failed to create environment: {e}"))?; println!("Created environment: {}", created.id); @@ -133,8 +136,8 @@ fn create( }; let created = ctx - .db() - .upsert_environment(&environment, &UpdateSource::Sync) + .query_manager() + .with_tx(|tx| tx.upsert_environment(&environment, &UpdateSource::Sync)) .map_err(|e| format!("Failed to create environment: {e}"))?; println!("Created environment: {}", created.id); @@ -152,8 +155,8 @@ fn update(ctx: &CliContext, json: Option, json_input: Option) -> let updated = apply_merge_patch(&existing, &patch, &id, "environment update")?; let saved = ctx - .db() - .upsert_environment(&updated, &UpdateSource::Sync) + .query_manager() + .with_tx(|tx| tx.upsert_environment(&updated, &UpdateSource::Sync)) .map_err(|e| format!("Failed to update environment: {e}"))?; println!("Updated environment: {}", saved.id); @@ -167,8 +170,8 @@ fn delete(ctx: &CliContext, environment_id: &str, yes: bool) -> CommandResult { } let deleted = ctx - .db() - .delete_environment_by_id(environment_id, &UpdateSource::Sync) + .query_manager() + .with_tx(|tx| tx.delete_environment_by_id(environment_id, &UpdateSource::Sync)) .map_err(|e| format!("Failed to delete environment: {e}"))?; println!("Deleted environment: {}", deleted.id); diff --git a/crates-cli/yaak-cli/src/commands/folder.rs b/crates-cli/yaak-cli/src/commands/folder.rs index 71eab72cc..7b90815d9 100644 --- a/crates-cli/yaak-cli/src/commands/folder.rs +++ b/crates-cli/yaak-cli/src/commands/folder.rs @@ -102,8 +102,8 @@ fn create( )?; let created = ctx - .db() - .upsert_folder(&folder, &UpdateSource::Sync) + .query_manager() + .with_tx(|tx| tx.upsert_folder(&folder, &UpdateSource::Sync)) .map_err(|e| format!("Failed to create folder: {e}"))?; println!("Created folder: {}", created.id); @@ -118,8 +118,8 @@ fn create( let folder = Folder { workspace_id, name, ..Default::default() }; let created = ctx - .db() - .upsert_folder(&folder, &UpdateSource::Sync) + .query_manager() + .with_tx(|tx| tx.upsert_folder(&folder, &UpdateSource::Sync)) .map_err(|e| format!("Failed to create folder: {e}"))?; println!("Created folder: {}", created.id); @@ -135,8 +135,8 @@ fn update(ctx: &CliContext, json: Option, json_input: Option) -> let updated = apply_merge_patch(&existing, &patch, &id, "folder update")?; let saved = ctx - .db() - .upsert_folder(&updated, &UpdateSource::Sync) + .query_manager() + .with_tx(|tx| tx.upsert_folder(&updated, &UpdateSource::Sync)) .map_err(|e| format!("Failed to update folder: {e}"))?; println!("Updated folder: {}", saved.id); @@ -150,8 +150,8 @@ fn delete(ctx: &CliContext, folder_id: &str, yes: bool) -> CommandResult { } let deleted = ctx - .db() - .delete_folder_by_id(folder_id, &UpdateSource::Sync) + .query_manager() + .with_tx(|tx| tx.delete_folder_by_id(folder_id, &UpdateSource::Sync)) .map_err(|e| format!("Failed to delete folder: {e}"))?; println!("Deleted folder: {}", deleted.id); diff --git a/crates-cli/yaak-cli/src/commands/import_export.rs b/crates-cli/yaak-cli/src/commands/import_export.rs index cf6bc2f93..89f60a7b3 100644 --- a/crates-cli/yaak-cli/src/commands/import_export.rs +++ b/crates-cli/yaak-cli/src/commands/import_export.rs @@ -2,13 +2,12 @@ use crate::cli::{ExportArgs, ImportArgs}; use crate::context::CliContext; use crate::utils::workspace::resolve_workspace_id; use std::fs; -use std::io::ErrorKind; -use yaak::export::{self, ExportDataParams}; use yaak::import; +use yaak_models::export::{self, ExportDataParams}; use yaak_models::util::{ BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlanAction, ImportPlanItem, }; -use yaak_plugins::events::{ImportResources, PluginContext}; +use yaak_plugins::events::{ImportRequest, ImportResources, PluginContext}; type CommandResult = std::result::Result; @@ -51,11 +50,12 @@ async fn import( .map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?; } - let file_contents = read_import_file(&args.file)?; + let input = ImportRequest::from_path(&args.file) + .map_err(|err| format!("Unable to read import source {}: {err}", args.file.display()))?; let plugin_context = PluginContext::new(None, args.workspace_id.clone()); let plugin_manager = ctx.plugin_manager(); let import_result = plugin_manager - .import_data(&plugin_context, &file_contents) + .import_input(&plugin_context, &input) .await .map_err(|e| format!("Failed to import data: {e}"))?; let importer = import_result.importer; @@ -68,7 +68,9 @@ async fn import( ); } let destination = match workspace_id { - Some(workspace_id) => ImportDestination::ExistingWorkspace { workspace_id, folder_id: None }, + Some(workspace_id) => { + ImportDestination::ExistingWorkspace { workspace_id, folder_id: None } + } None => ImportDestination::NewWorkspace, }; let plan = import::plan_import_resources( @@ -113,6 +115,10 @@ fn format_skipped(items: &[ImportPlanItem]) -> Option { if keep_local > 0 { parts.push(format!("{keep_local} with local edits")); } + let ignored = count(ImportPlanAction::Ignored); + if ignored > 0 { + parts.push(format!("{ignored} ignored")); + } let unchanged = count(ImportPlanAction::Unchanged); if unchanged > 0 { parts.push(format!("{unchanged} unchanged")); @@ -121,17 +127,61 @@ fn format_skipped(items: &[ImportPlanItem]) -> Option { if parts.is_empty() { None } else { Some(parts.join(", ")) } } +/// Write a file so that afterwards it is either the old one or the whole new one. +/// +/// Writing in place would truncate the previous export before the new one existed, so an +/// interrupted run loses both. Instead the bytes go to a neighbouring file, are flushed, and +/// take the target's name in one step. The directory is flushed too: `sync_all` on a file +/// promises its contents, not that the name it just gained will survive a power cut. +fn write_durably(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + + let dir = match path.parent() { + Some(p) if !p.as_os_str().is_empty() => p, + _ => std::path::Path::new("."), + }; + // Alongside the target, because a rename is only atomic within one filesystem. + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(format!(".tmp{}", std::process::id())); + let tmp = dir.join(name); + + let write = |tmp: &std::path::Path| -> std::io::Result<()> { + let mut file = std::fs::File::create(tmp)?; + file.write_all(bytes)?; + file.sync_all() + }; + if let Err(e) = write(&tmp) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + if let Err(e) = std::fs::rename(&tmp, path) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + + // Only Unix lets a directory be opened for this. Elsewhere the rename is as much as + // the platform offers, and the file itself is already flushed. + #[cfg(unix)] + std::fs::File::open(dir)?.sync_all()?; + + Ok(()) +} + fn export(ctx: &CliContext, args: ExportArgs) -> CommandResult { let workspace_ids = resolve_export_workspace_ids(ctx, args.workspace_ids, args.all)?; let workspace_id_refs: Vec<&str> = workspace_ids.iter().map(String::as_str).collect(); - export::export_data(ExportDataParams { + let document = export::export_data(ExportDataParams { query_manager: ctx.query_manager(), yaak_version: env!("CARGO_PKG_VERSION"), - export_path: &args.file, workspace_ids: workspace_id_refs, include_private_environments: args.include_private_environments, }) .map_err(|e| format!("Failed to export data: {e}"))?; + // Flushed before reporting success: an export is a backup, and a backup that the + // command called done while it was still only in the page cache is the one case + // where saying nothing went wrong is worst. + write_durably(&args.file, document.as_bytes()) + .map_err(|e| format!("Failed to write {}: {e}", args.file.display()))?; Ok(workspace_ids.len()) } @@ -162,19 +212,6 @@ fn resolve_export_workspace_ids( Ok(workspace_ids) } -fn read_import_file(path: &std::path::Path) -> CommandResult { - fs::read_to_string(path).map_err(|err| { - if err.kind() == ErrorKind::InvalidData { - format!( - "Import file must be UTF-8 text; binary files are not supported: {}", - path.display() - ) - } else { - format!("Unable to read import file {}: {err}", path.display()) - } - }) -} - fn resources_need_current_workspace(resources: &ImportResources) -> bool { resources.workspaces.iter().any(|w| w.id == "CURRENT_WORKSPACE") || resources.environments.iter().any(|e| { diff --git a/crates-cli/yaak-cli/src/commands/mcp.rs b/crates-cli/yaak-cli/src/commands/mcp.rs new file mode 100644 index 000000000..e5356bd43 --- /dev/null +++ b/crates-cli/yaak-cli/src/commands/mcp.rs @@ -0,0 +1,704 @@ +use crate::cli::McpArgs; +use crate::context::{CliContext, CliExecutionContext}; +use crate::plugin_events::SendContexts; +use rand::RngExt; +use std::net::{Ipv4Addr, SocketAddr}; +use std::path::PathBuf; +use std::sync::Arc; +use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins}; +use yaak_crypto::manager::EncryptionManager; +use yaak_http::manager::HttpConnectionManager; +use yaak_mcp::{HostResult, McpHost, WorkspaceInfo}; +use yaak_models::blob_manager::BlobManager; +use yaak_models::models::{Folder, HttpRequest, HttpResponse}; +use yaak_models::query_manager::QueryManager; +use yaak_models::util::UpdateSource; +use yaak_plugins::events::{PluginContext, ShowToastRequest}; +use yaak_plugins::manager::PluginManager; + +enum Transport { + Stdio, + Http { port: u16, token: Option }, +} + +impl From for Transport { + fn from(args: McpArgs) -> Self { + if args.http { + Transport::Http { port: args.port, token: args.token } + } else { + Transport::Stdio + } + } +} + +pub async fn run( + ctx: &CliContext, + args: McpArgs, + environment: Option<&str>, + cookie_jar_id: Option<&str>, +) -> i32 { + let host = CliMcpHost { + query_manager: ctx.query_manager().clone(), + blob_manager: ctx.blob_manager().clone(), + plugin_manager: ctx.plugin_manager(), + encryption_manager: ctx.encryption_manager.clone(), + connection_manager: ctx.connection_manager().clone(), + response_dir: ctx.data_dir().join("responses"), + send_contexts: ctx.send_contexts().await, + environment_id: environment.map(str::to_string), + cookie_jar_id: cookie_jar_id.map(str::to_string), + }; + let result = match Transport::from(args) { + Transport::Stdio => yaak_mcp::serve_stdio(host).await, + Transport::Http { port, token } => serve_http(host, port, token).await, + }; + match result { + Ok(()) => 0, + Err(error) => { + eprintln!("Error: {error}"); + 1 + } + } +} + +async fn serve_http(host: CliMcpHost, port: u16, token: Option) -> Result<(), String> { + let token = token.unwrap_or_else(generate_token); + let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + let server = yaak_mcp::http::serve(Arc::new(host), addr, token.clone()) + .await + .map_err(|e| format!("Failed to start MCP server on {addr}: {e}"))?; + eprintln!("Yaak MCP server listening on http://{}{}", server.addr, yaak_mcp::http::PATH); + eprintln!("Token: {token}"); + eprintln!("Clients must send it as \"Authorization: Bearer \". Press Ctrl+C to stop."); + tokio::signal::ctrl_c().await.map_err(|e| format!("Failed to wait for Ctrl+C: {e}"))?; + server.stop().await; + Ok(()) +} + +fn generate_token() -> String { + hex::encode(rand::rng().random::<[u8; 32]>()) +} + +/// Every workspace in the database is "open", and the global `--environment` +/// and `--cookie-jar` flags stand in for a window's selection. +struct CliMcpHost { + query_manager: QueryManager, + blob_manager: BlobManager, + plugin_manager: Arc, + encryption_manager: Arc, + connection_manager: Arc, + response_dir: PathBuf, + send_contexts: SendContexts, + environment_id: Option, + cookie_jar_id: Option, +} + +/// Registers a send's execution context for as long as the send runs. +struct SendContextGuard<'a> { + send_contexts: &'a SendContexts, + id: String, +} + +impl<'a> SendContextGuard<'a> { + fn new(send_contexts: &'a SendContexts, id: &str, context: CliExecutionContext) -> Self { + send_contexts.lock().expect("send contexts lock").insert(id.to_string(), context); + Self { send_contexts, id: id.to_string() } + } +} + +impl Drop for SendContextGuard<'_> { + fn drop(&mut self) { + self.send_contexts.lock().expect("send contexts lock").remove(&self.id); + } +} + +impl McpHost for CliMcpHost { + fn list_workspaces(&self) -> HostResult> { + let workspaces = + self.query_manager.connect().list_workspaces().map_err(|e| e.to_string())?; + Ok(workspaces.into_iter().map(|w| WorkspaceInfo { id: w.id, name: w.name }).collect()) + } + + fn active_environment_id(&self, workspace_id: &str) -> HostResult> { + let Some(environment_id) = &self.environment_id else { + return Ok(None); + }; + Ok(self + .query_manager + .connect() + .get_environment_for_workspace(workspace_id, environment_id) + .ok() + .map(|e| e.id)) + } + + fn list_http_requests(&self, workspace_id: &str) -> HostResult> { + self.query_manager.connect().list_http_requests(workspace_id).map_err(|e| e.to_string()) + } + + fn get_http_request(&self, id: &str) -> HostResult> { + Ok(self.query_manager.connect().get_http_request(id).ok()) + } + + fn upsert_http_request(&self, request: &HttpRequest) -> HostResult { + self.query_manager + .with_tx(|tx| tx.upsert_http_request(request, &UpdateSource::Sync)) + .map_err(|e| format!("Failed to upsert HTTP request: {e}")) + } + + fn delete_http_request(&self, id: &str) -> HostResult { + self.query_manager + .with_tx(|tx| tx.delete_http_request_by_id(id, &UpdateSource::Sync)) + .map_err(|e| format!("Failed to delete HTTP request: {e}")) + } + + fn list_folders(&self, workspace_id: &str) -> HostResult> { + self.query_manager.connect().list_folders(workspace_id).map_err(|e| e.to_string()) + } + + fn upsert_folder(&self, folder: &Folder) -> HostResult { + self.query_manager + .with_tx(|tx| tx.upsert_folder(folder, &UpdateSource::Sync)) + .map_err(|e| format!("Failed to upsert folder: {e}")) + } + + fn delete_folder(&self, id: &str) -> HostResult { + self.query_manager + .with_tx(|tx| tx.delete_folder_by_id(id, &UpdateSource::Sync)) + .map_err(|e| format!("Failed to delete folder: {e}")) + } + + async fn send_http_request( + &self, + workspace_id: &str, + request: HttpRequest, + environment_id: Option, + ) -> HostResult { + let environment_id = match environment_id { + Some(environment_id) => { + if request.workspace_id != workspace_id { + return Err("HTTP request does not belong to the selected workspace".into()); + } + let environment = self + .query_manager + .connect() + .get_environment_for_workspace(&request.workspace_id, &environment_id) + .map_err(|e| e.to_string())?; + Some(environment.id) + } + None => self.active_environment_id(&request.workspace_id)?, + }; + let cookie_jar_id = match &self.cookie_jar_id { + Some(id) => Some(id.clone()), + None => self + .query_manager + .connect() + .list_cookie_jars(&request.workspace_id) + .map_err(|e| format!("Failed to list cookie jars: {e}"))? + .into_iter() + .min_by_key(|jar| jar.created_at) + .map(|jar| jar.id), + }; + let plugin_context = + PluginContext::new(Some("cli".to_string()), Some(request.workspace_id.clone())); + let _send_context = SendContextGuard::new( + &self.send_contexts, + &plugin_context.id, + CliExecutionContext { + request_id: Some(request.id.clone()), + workspace_id: Some(request.workspace_id.clone()), + environment_id: environment_id.clone(), + cookie_jar_id: cookie_jar_id.clone(), + }, + ); + + let result = send_http_request_with_plugins(SendHttpRequestWithPluginsParams { + query_manager: &self.query_manager, + blob_manager: &self.blob_manager, + request, + environment_id: environment_id.as_deref(), + update_source: UpdateSource::Sync, + cookie_jar_id, + response_dir: &self.response_dir, + emit_events_to: None, + emit_response_body_chunks_to: None, + existing_response: None, + plugin_manager: self.plugin_manager.clone(), + encryption_manager: self.encryption_manager.clone(), + plugin_context: &plugin_context, + cancelled_rx: None, + connection_manager: &self.connection_manager, + }) + .await + .map_err(|e| e.to_string())?; + Ok(result.response) + } + + fn show_toast(&self, toast: ShowToastRequest) -> HostResult<()> { + // stdout carries the protocol + eprintln!("[mcp] {}", toast.message); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rmcp::model::{CallToolRequestParams, CallToolResult}; + use rmcp::service::RunningService; + use rmcp::{RoleClient, ServiceExt}; + use serde_json::{Value, json}; + use tempfile::TempDir; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + use tokio::sync::mpsc::UnboundedReceiver; + use yaak_mcp::YaakMcpServer; + use yaak_models::models::{Environment, EnvironmentVariable, HttpRequestHeader, Workspace}; + + struct Fixture { + client: RunningService, + query_manager: QueryManager, + plugin_manager: Arc, + workspace_id: String, + request_id: String, + environments: [String; 3], + received: UnboundedReceiver, + _dir: TempDir, + } + + impl Fixture { + async fn new(active_environment: Option<&str>) -> Self { + let dir = TempDir::new().unwrap(); + let (query_manager, blob_manager, _rx) = yaak_models::init_standalone( + dir.path().join("db.sqlite"), + dir.path().join("blobs.sqlite"), + ) + .unwrap(); + let (url, received) = echo_server().await; + let (workspace_id, request_id, environments) = seed(&query_manager, &url); + + let plugin_dir = dir.path().join("plugins"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let plugin_manager = Arc::new( + PluginManager::new( + plugin_dir.clone(), + plugin_dir, + PathBuf::from("node"), + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( + "../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs", + ), + &query_manager, + &PluginContext::new_empty(), + false, + ) + .await + .unwrap(), + ); + let environment_id = active_environment.map(|name| match name { + "a" => environments[1].clone(), + other => other.to_string(), + }); + let host = CliMcpHost { + encryption_manager: Arc::new(EncryptionManager::new( + query_manager.clone(), + "yaak-test", + )), + query_manager: query_manager.clone(), + blob_manager, + plugin_manager: plugin_manager.clone(), + connection_manager: Arc::new(HttpConnectionManager::new()), + response_dir: dir.path().join("responses"), + send_contexts: SendContexts::default(), + environment_id, + cookie_jar_id: None, + }; + + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + tokio::spawn(async move { + let server = YaakMcpServer::new(host).serve(server_io).await.unwrap(); + let _ = server.waiting().await; + }); + let client = ().serve(client_io).await.unwrap(); + + Self { + client, + query_manager, + plugin_manager, + workspace_id, + request_id, + environments, + received, + _dir: dir, + } + } + + async fn call(&self, name: &'static str, arguments: Value) -> CallToolResult { + let Value::Object(arguments) = arguments else { + panic!("arguments must be an object") + }; + self.client + .call_tool(CallToolRequestParams::new(name).with_arguments(arguments)) + .await + .unwrap() + } + + async fn ok(&self, name: &'static str, arguments: Value) -> String { + let result = self.call(name, arguments).await; + assert_ne!(result.is_error, Some(true), "{name} failed: {}", text(&result)); + text(&result) + } + + async fn err(&self, name: &'static str, arguments: Value) -> String { + let result = self.call(name, arguments).await; + assert_eq!(result.is_error, Some(true), "{name} should fail: {}", text(&result)); + text(&result) + } + + async fn json(&self, name: &'static str, arguments: Value) -> Value { + serde_json::from_str(&self.ok(name, arguments).await).unwrap() + } + + async fn next_marker(&mut self) -> String { + let request = self.received.recv().await.unwrap().to_lowercase(); + let line = request.lines().find(|l| l.starts_with("x-environment:")).unwrap(); + line["x-environment:".len()..].trim().to_string() + } + + async fn finish(self) { + assert!(self.received.is_empty(), "unexpected requests reached the server"); + self.client.cancel().await.unwrap(); + self.plugin_manager.terminate().await; + } + } + + fn text(result: &CallToolResult) -> String { + result.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.clone()).collect() + } + + async fn echo_server() -> (String, UnboundedReceiver) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/echo", listener.local_addr().unwrap()); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + while !request.windows(4).any(|w| w == b"\r\n\r\n") { + let mut chunk = [0u8; 1024]; + let n = socket.read(&mut chunk).await.unwrap(); + if n == 0 { + break; + } + request.extend_from_slice(&chunk[..n]); + } + tx.send(String::from_utf8(request).unwrap()).unwrap(); + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok", + ) + .await + .unwrap(); + } + }); + (url, rx) + } + + /// A workspace with base environment "global", sub-environments "a" and + /// "b", and a request that echoes its environment's marker in a header. + fn seed(query_manager: &QueryManager, url: &str) -> (String, String, [String; 3]) { + query_manager + .with_tx(|db| { + let source = &UpdateSource::Background; + let workspace = db.upsert_workspace( + &Workspace { name: "Test".into(), ..Default::default() }, + source, + )?; + let vars = |value: &str| { + vec![EnvironmentVariable { + enabled: true, + name: "marker".into(), + value: value.into(), + ..Default::default() + }] + }; + let base = db.ensure_base_environment(&workspace.id)?; + let base = db.upsert_environment( + &Environment { variables: vars("global"), ..base }, + source, + )?; + let sub = |name: &str| { + db.upsert_environment( + &Environment { + name: name.into(), + workspace_id: workspace.id.clone(), + parent_model: "environment".into(), + parent_id: Some(base.id.clone()), + variables: vars(name), + ..Default::default() + }, + source, + ) + }; + let a = sub("a")?; + let b = sub("b")?; + let request = db.upsert_http_request( + &HttpRequest { + workspace_id: workspace.id.clone(), + name: "Echo".into(), + url: url.to_string(), + method: "GET".into(), + headers: vec![HttpRequestHeader { + enabled: true, + name: "X-Environment".into(), + value: "${[ marker ]}".into(), + ..Default::default() + }], + ..Default::default() + }, + source, + )?; + Ok::<_, yaak_models::error::Error>(( + workspace.id, + request.id, + [base.id, a.id, b.id], + )) + }) + .unwrap() + } + + #[tokio::test] + async fn advertises_the_plugin_tools() { + let fx = Fixture::new(None).await; + let tools = fx.client.list_all_tools().await.unwrap(); + let mut names = tools.iter().map(|t| t.name.to_string()).collect::>(); + names.sort(); + assert_eq!( + names, + [ + "create_folder", + "create_http_request", + "delete_folder", + "delete_http_request", + "get_environment_id", + "get_folder", + "get_http_request", + "get_workspace_id", + "list_folders", + "list_http_requests", + "list_workspaces", + "send_http_request", + "show_toast", + "update_folder", + "update_http_request", + ] + ); + let send = tools.iter().find(|t| t.name == "send_http_request").unwrap(); + let properties = send.input_schema["properties"].as_object().unwrap(); + assert!(properties.contains_key("environmentId"), "{properties:?}"); + assert_eq!(send.input_schema["required"], json!(["id"])); + fx.finish().await; + } + + #[tokio::test] + async fn send_uses_the_given_environment() { + let mut fx = Fixture::new(Some("a")).await; + let response = fx + .json( + "send_http_request", + json!({ "id": fx.request_id, "workspaceId": fx.workspace_id, "environmentId": fx.environments[2] }), + ) + .await; + assert_eq!(response["status"], 200); + assert!(response.get("bodyPath").is_none(), "{response}"); + assert_eq!(fx.next_marker().await, "b"); + fx.finish().await; + } + + #[tokio::test] + async fn send_without_an_environment_uses_the_active_one() { + let mut fx = Fixture::new(Some("a")).await; + fx.ok("send_http_request", json!({ "id": fx.request_id })).await; + assert_eq!(fx.next_marker().await, "a"); + fx.finish().await; + + let mut fx = Fixture::new(None).await; + fx.ok("send_http_request", json!({ "id": fx.request_id })).await; + assert_eq!(fx.next_marker().await, "global"); + fx.finish().await; + } + + #[tokio::test] + async fn send_does_not_send_a_missing_request() { + let fx = Fixture::new(None).await; + let error = fx.err("send_http_request", json!({ "id": "rq_missing" })).await; + assert_eq!(error, "HTTP request with ID rq_missing not found"); + fx.finish().await; + } + + #[tokio::test] + async fn send_returns_host_environment_errors() { + let fx = Fixture::new(None).await; + let expected = fx + .query_manager + .connect() + .get_environment_for_workspace(&fx.workspace_id, "ev_missing") + .unwrap_err() + .to_string(); + let error = fx + .err("send_http_request", json!({ "id": fx.request_id, "environmentId": "ev_missing" })) + .await; + assert_eq!(error, expected); + fx.finish().await; + } + + #[tokio::test] + async fn concurrent_sends_keep_their_environments() { + let mut fx = Fixture::new(None).await; + let (a, b) = (fx.environments[1].clone(), fx.environments[2].clone()); + tokio::join!( + fx.ok("send_http_request", json!({ "id": fx.request_id, "environmentId": a })), + fx.ok("send_http_request", json!({ "id": fx.request_id, "environmentId": b })), + ); + let mut markers = vec![fx.next_marker().await, fx.next_marker().await]; + markers.sort(); + assert_eq!(markers, ["a", "b"]); + let responses = + fx.query_manager.connect().list_http_responses_for_request(&fx.request_id, None); + assert_eq!(responses.unwrap().len(), 2); + fx.finish().await; + } + + #[tokio::test] + async fn workspace_and_environment_tools() { + let fx = Fixture::new(Some("a")).await; + assert_eq!( + fx.json("list_workspaces", json!({})).await, + json!([{ "id": fx.workspace_id, "name": "Test" }]) + ); + assert_eq!(fx.ok("get_workspace_id", json!({})).await, fx.workspace_id); + assert_eq!(fx.ok("get_environment_id", json!({})).await, fx.environments[1]); + + let error = fx.err("list_http_requests", json!({ "workspaceId": "wk_missing" })).await; + assert!(error.starts_with("Workspace with ID \"wk_missing\" not found."), "{error}"); + + fx.query_manager + .with_tx(|db| { + db.upsert_workspace( + &Workspace { name: "Other".into(), ..Default::default() }, + &UpdateSource::Background, + ) + }) + .unwrap(); + let error = fx.err("get_workspace_id", json!({})).await; + assert!(error.starts_with("Multiple workspaces are open."), "{error}"); + let error = fx.err("list_http_requests", json!({})).await; + assert!(error.contains(&format!("Test (ID: {})", fx.workspace_id)), "{error}"); + let requests = + fx.json("list_http_requests", json!({ "workspaceId": fx.workspace_id })).await; + assert_eq!(requests.as_array().unwrap().len(), 1); + fx.finish().await; + + let fx = Fixture::new(None).await; + assert_eq!(fx.ok("get_environment_id", json!({})).await, "No environment selected"); + fx.finish().await; + } + + #[tokio::test] + async fn http_request_tools() { + let fx = Fixture::new(None).await; + let created = fx + .json( + "create_http_request", + json!({ + "name": "Created", + "url": "https://example.com/items", + "headers": [{ "name": "Accept", "value": "application/json" }], + "bodyType": "application/json", + "body": { "text": "{}" }, + }), + ) + .await; + let id = created["id"].as_str().unwrap().to_string(); + assert!(id.starts_with("rq_"), "{created}"); + assert_eq!(created["workspaceId"], fx.workspace_id); + assert_eq!(created["method"], "GET"); + assert_eq!(created["headers"][0]["enabled"], true); + assert_eq!(fx.json("get_http_request", json!({ "id": id })).await, created); + + let updated = fx + .json( + "update_http_request", + json!({ "id": id, "url": "https://example.com/v2", "method": "POST" }), + ) + .await; + assert_eq!(updated["id"], id); + assert_eq!(updated["url"], "https://example.com/v2"); + assert_eq!(updated["method"], "POST"); + assert_eq!(updated["name"], "Created"); + assert_eq!(updated["body"], json!({ "text": "{}" })); + + let error = fx.err("update_http_request", json!({ "id": "rq_missing", "name": "x" })).await; + assert_eq!(error, "HTTP request with ID rq_missing not found"); + + let listed = fx.json("list_http_requests", json!({})).await; + assert_eq!(listed.as_array().unwrap().len(), 2); + + assert_eq!( + fx.ok("delete_http_request", json!({ "id": id })).await, + format!("Deleted: Created ({id})") + ); + assert_eq!(fx.json("get_http_request", json!({ "id": id })).await, Value::Null); + fx.finish().await; + } + + #[tokio::test] + async fn folder_tools() { + let fx = Fixture::new(None).await; + let parent = fx.json("create_folder", json!({ "name": "Parent" })).await; + let parent_id = parent["id"].as_str().unwrap().to_string(); + assert!(parent_id.starts_with("fl_"), "{parent}"); + let child = fx + .json( + "create_folder", + json!({ + "name": "Child", + "folderId": parent_id, + "sortPriority": 2.5, + "authenticationType": "bearer", + "authentication": { "token": "abc" }, + }), + ) + .await; + let child_id = child["id"].as_str().unwrap().to_string(); + assert_eq!(child["folderId"], parent_id); + assert_eq!(child["authentication"]["token"], "abc"); + assert_eq!(fx.json("get_folder", json!({ "id": child_id })).await, child); + assert_eq!(fx.json("list_folders", json!({})).await.as_array().unwrap().len(), 2); + + let updated = + fx.json("update_folder", json!({ "id": child_id, "description": "Nested" })).await; + assert_eq!(updated["description"], "Nested"); + assert_eq!(updated["name"], "Child"); + assert_eq!(updated["folderId"], parent_id); + + let error = fx.err("update_folder", json!({ "id": "fl_missing" })).await; + assert_eq!(error, "Folder with ID fl_missing not found"); + + assert_eq!( + fx.ok("delete_folder", json!({ "id": child_id })).await, + format!("Deleted: Child ({child_id})") + ); + assert_eq!(fx.json("get_folder", json!({ "id": child_id })).await, Value::Null); + fx.finish().await; + } + + #[tokio::test] + async fn show_toast_reports_the_message() { + let fx = Fixture::new(None).await; + assert_eq!( + fx.ok("show_toast", json!({ "message": "Hello", "icon": "info", "color": "success" })) + .await, + "✓ Toast shown: \"Hello\"" + ); + fx.finish().await; + } +} diff --git a/crates-cli/yaak-cli/src/commands/mod.rs b/crates-cli/yaak-cli/src/commands/mod.rs index 901371f1a..6f4e467b6 100644 --- a/crates-cli/yaak-cli/src/commands/mod.rs +++ b/crates-cli/yaak-cli/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod cookie_jar; pub mod environment; pub mod folder; pub mod import_export; +pub mod mcp; pub mod plugin; pub mod request; pub mod response; diff --git a/crates-cli/yaak-cli/src/commands/plugin.rs b/crates-cli/yaak-cli/src/commands/plugin.rs index 951042c5a..9aeb73c40 100644 --- a/crates-cli/yaak-cli/src/commands/plugin.rs +++ b/crates-cli/yaak-cli/src/commands/plugin.rs @@ -3,7 +3,7 @@ use crate::context::CliContext; use crate::ui; use crate::utils::http; use keyring::Entry; -use rand::Rng; +use rand::RngExt; use rolldown::{ BundleEvent, Bundler, BundlerOptions, ExperimentalOptions, InputItem, LogLevel, OutputFormat, Platform, WatchOption, Watcher, WatcherEvent, @@ -348,17 +348,19 @@ async fn install_from_directory(context: &CliContext, source: &str) -> CommandRe ui::info(&format!("Installing plugin from directory {}", plugin_dir.display())); let plugin = context - .db() - .upsert_plugin( - &Plugin { - directory: plugin_dir_str, - url: None, - enabled: true, - source: PluginSource::Filesystem, - ..Default::default() - }, - &UpdateSource::Background, - ) + .query_manager() + .with_tx(|tx| { + tx.upsert_plugin( + &Plugin { + directory: plugin_dir_str, + url: None, + enabled: true, + source: PluginSource::Filesystem, + ..Default::default() + }, + &UpdateSource::Background, + ) + }) .map_err(|err| format!("Failed to save plugin in database: {err}"))?; let plugin_context = PluginContext::new(Some("cli".to_string()), None); @@ -722,9 +724,9 @@ fn random_name() -> String { "yak", "yarn", "year", "yell", "yoke", "yoga", "yam", "yacht", "yodel", ]; - let mut rng = rand::thread_rng(); - let adjective = ADJECTIVES[rng.gen_range(0..ADJECTIVES.len())]; - let noun = NOUNS[rng.gen_range(0..NOUNS.len())]; + let mut rng = rand::rng(); + let adjective = ADJECTIVES[rng.random_range(0..ADJECTIVES.len())]; + let noun = NOUNS[rng.random_range(0..NOUNS.len())]; format!("{adjective}-{noun}") } @@ -848,7 +850,7 @@ mod tests { let mut names = HashSet::new(); for i in 0..zip.len() { let file = zip.by_index(i).expect("zip entry"); - names.insert(file.name().to_string()); + names.insert(file.name().expect("zip entry name").to_string()); } assert!(names.contains("README.md")); @@ -883,11 +885,8 @@ mod tests { 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"); + fs::write(root.join("package.json"), r#"{"yaak":{"buildAssets":["vendor/core_bg.wasm"]}}"#) + .expect("write package.json"); copy_build_assets(root).expect("copy assets"); diff --git a/crates-cli/yaak-cli/src/commands/request.rs b/crates-cli/yaak-cli/src/commands/request.rs index ec37789ab..39070a735 100644 --- a/crates-cli/yaak-cli/src/commands/request.rs +++ b/crates-cli/yaak-cli/src/commands/request.rs @@ -424,8 +424,8 @@ fn create( )?; let created = ctx - .db() - .upsert_http_request(&request, &UpdateSource::Sync) + .query_manager() + .with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync)) .map_err(|e| format!("Failed to create request: {e}"))?; println!("Created request: {}", created.id); @@ -443,8 +443,8 @@ fn create( } let created = ctx - .db() - .upsert_http_request(&request, &UpdateSource::Sync) + .query_manager() + .with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync)) .map_err(|e| format!("Failed to create request: {e}"))?; println!("Created request: {}", created.id); @@ -463,8 +463,8 @@ fn update(ctx: &CliContext, json: Option, json_input: Option) -> let updated = apply_merge_patch(&existing, &patch, &id, "request update")?; let saved = ctx - .db() - .upsert_http_request(&updated, &UpdateSource::Sync) + .query_manager() + .with_tx(|tx| tx.upsert_http_request(&updated, &UpdateSource::Sync)) .map_err(|e| format!("Failed to update request: {e}"))?; println!("Updated request: {}", saved.id); @@ -487,8 +487,8 @@ fn delete(ctx: &CliContext, request_id: &str, yes: bool) -> CommandResult { } let deleted = ctx - .db() - .delete_http_request_by_id(request_id, &UpdateSource::Sync) + .query_manager() + .with_tx(|tx| tx.delete_http_request_by_id(request_id, &UpdateSource::Sync)) .map_err(|e| format!("Failed to delete request: {e}"))?; println!("Deleted request: {}", deleted.id); Ok(()) diff --git a/crates-cli/yaak-cli/src/commands/response.rs b/crates-cli/yaak-cli/src/commands/response.rs index 3a8b3ba82..57177a53e 100644 --- a/crates-cli/yaak-cli/src/commands/response.rs +++ b/crates-cli/yaak-cli/src/commands/response.rs @@ -118,8 +118,8 @@ fn delete(ctx: &CliContext, id: &str, yes: bool) -> CommandResult { return Ok(()); } let count = ctx - .db() - .delete_all_http_responses_for_request(id, &UpdateSource::Sync) + .query_manager() + .with_tx(|tx| tx.delete_all_http_responses_for_request(id, &UpdateSource::Sync)) .map_err(|e| format!("Failed to delete responses: {e}"))?; println!("Deleted {count} responses for request {id}"); return Ok(()); @@ -131,8 +131,10 @@ fn delete(ctx: &CliContext, id: &str, yes: bool) -> CommandResult { return Ok(()); } let count = ctx - .db() - .delete_all_http_responses_for_workspace(&workspace_id, &UpdateSource::Sync) + .query_manager() + .with_tx(|tx| { + tx.delete_all_http_responses_for_workspace(&workspace_id, &UpdateSource::Sync) + }) .map_err(|e| format!("Failed to delete responses: {e}"))?; println!("Deleted {count} responses for workspace {workspace_id}"); Ok(()) diff --git a/crates-cli/yaak-cli/src/commands/workspace.rs b/crates-cli/yaak-cli/src/commands/workspace.rs index 9a05219b3..cc1fc08cc 100644 --- a/crates-cli/yaak-cli/src/commands/workspace.rs +++ b/crates-cli/yaak-cli/src/commands/workspace.rs @@ -84,8 +84,8 @@ fn create( .map_err(|e| format!("Failed to parse workspace create JSON: {e}"))?; let created = ctx - .db() - .upsert_workspace(&workspace, &UpdateSource::Sync) + .query_manager() + .with_tx(|tx| tx.upsert_workspace(&workspace, &UpdateSource::Sync)) .map_err(|e| format!("Failed to create workspace: {e}"))?; println!("Created workspace: {}", created.id); return Ok(()); @@ -97,8 +97,8 @@ fn create( let workspace = Workspace { name, ..Default::default() }; let created = ctx - .db() - .upsert_workspace(&workspace, &UpdateSource::Sync) + .query_manager() + .with_tx(|tx| tx.upsert_workspace(&workspace, &UpdateSource::Sync)) .map_err(|e| format!("Failed to create workspace: {e}"))?; println!("Created workspace: {}", created.id); Ok(()) @@ -115,8 +115,8 @@ fn update(ctx: &CliContext, json: Option, json_input: Option) -> let updated = apply_merge_patch(&existing, &patch, &id, "workspace update")?; let saved = ctx - .db() - .upsert_workspace(&updated, &UpdateSource::Sync) + .query_manager() + .with_tx(|tx| tx.upsert_workspace(&updated, &UpdateSource::Sync)) .map_err(|e| format!("Failed to update workspace: {e}"))?; println!("Updated workspace: {}", saved.id); @@ -130,8 +130,10 @@ fn delete(ctx: &CliContext, workspace_id: &str, yes: bool) -> CommandResult { } let deleted = ctx - .db() - .delete_workspace_by_id(workspace_id, &UpdateSource::Sync, ctx.blob_manager()) + .query_manager() + .with_tx(|tx| { + tx.delete_workspace_by_id(workspace_id, &UpdateSource::Sync, ctx.blob_manager()) + }) .map_err(|e| format!("Failed to delete workspace: {e}"))?; println!("Deleted workspace: {}", deleted.id); Ok(()) diff --git a/crates-cli/yaak-cli/src/context.rs b/crates-cli/yaak-cli/src/context.rs index be30439f5..df276087f 100644 --- a/crates-cli/yaak-cli/src/context.rs +++ b/crates-cli/yaak-cli/src/context.rs @@ -1,4 +1,4 @@ -use crate::plugin_events::CliPluginEventBridge; +use crate::plugin_events::{CliPluginEventBridge, SendContexts}; use include_dir::{Dir, include_dir}; use std::fs; use std::path::{Path, PathBuf}; @@ -51,11 +51,9 @@ impl CliContext { }; // 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 _ = query_manager.with_tx(|tx| { + yaak_lifecycle::on_launch(&yaak_lifecycle::Host::guest(), tx, &blob_manager) + }); let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id)); @@ -132,10 +130,18 @@ impl CliContext { &self.blob_manager } - pub fn connection_manager(&self) -> &HttpConnectionManager { + pub fn connection_manager(&self) -> &Arc { &self.connection_manager } + /// Where a send registers its execution context for the plugin calls it makes. + pub async fn send_contexts(&self) -> SendContexts { + match &*self.plugin_event_bridge.lock().await { + Some(bridge) => bridge.send_contexts(), + None => SendContexts::default(), + } + } + pub fn plugin_manager(&self) -> Arc { self.plugin_manager.clone().expect("Plugin manager was not initialized for this command") } diff --git a/crates-cli/yaak-cli/src/main.rs b/crates-cli/yaak-cli/src/main.rs index b6020378a..f18edf39b 100644 --- a/crates-cli/yaak-cli/src/main.rs +++ b/crates-cli/yaak-cli/src/main.rs @@ -10,7 +10,7 @@ mod version_check; use clap::{CommandFactory, FromArgMatches}; use cli::{AGENT_HINTS, Cli, Commands, PluginCommands, RequestCommands}; use context::{CliContext, CliExecutionContext}; -use std::path::PathBuf; +use yaak_common::data_dir::resolve_data_dir; use yaak_models::queries::any_request::AnyRequest; /// Built at runtime so root `--help` can report whether the installed agent skill is @@ -184,6 +184,21 @@ async fn main() { context.shutdown().await; exit_code } + Commands::Mcp(args) => { + let mut context = CliContext::new(data_dir.clone(), app_id); + context + .init_plugins(CliExecutionContext { + environment_id: environment.clone(), + cookie_jar_id: cookie_jar.clone(), + ..CliExecutionContext::default() + }) + .await; + let exit_code = + commands::mcp::run(&context, args, environment.as_deref(), cookie_jar.as_deref()) + .await; + context.shutdown().await; + exit_code + } Commands::Environment(args) => { let context = CliContext::new(data_dir.clone(), app_id); let exit_code = commands::environment::run(&context, args); @@ -286,46 +301,3 @@ fn resolve_cookie_jar_id( .map(|jar| jar.id); Ok(default_cookie_jar) } - -fn resolve_data_dir(app_id: &str) -> PathBuf { - if let Some(dir) = wsl_data_dir(app_id) { - return dir; - } - dirs::data_dir().expect("Could not determine data directory").join(app_id) -} - -/// Detect WSL and resolve the Windows AppData\Roaming path for the Yaak data directory. -fn wsl_data_dir(app_id: &str) -> Option { - if !cfg!(target_os = "linux") { - return None; - } - - let proc_version = std::fs::read_to_string("/proc/version").ok()?; - let is_wsl = proc_version.to_lowercase().contains("microsoft"); - if !is_wsl { - return None; - } - - // We're in WSL, so try to resolve the Yaak app's data directory in Windows - - // Get the Windows %APPDATA% path via cmd.exe - let appdata_output = - std::process::Command::new("cmd.exe").args(["/C", "echo", "%APPDATA%"]).output().ok()?; - - let win_path = String::from_utf8(appdata_output.stdout).ok()?.trim().to_string(); - if win_path.is_empty() || win_path == "%APPDATA%" { - return None; - } - - // Convert Windows path to WSL path using wslpath (handles custom mount points) - let wslpath_output = std::process::Command::new("wslpath").arg(&win_path).output().ok()?; - - let wsl_appdata = String::from_utf8(wslpath_output.stdout).ok()?.trim().to_string(); - if wsl_appdata.is_empty() { - return None; - } - - let wsl_path = PathBuf::from(wsl_appdata).join(app_id); - - if wsl_path.exists() { Some(wsl_path) } else { None } -} diff --git a/crates-cli/yaak-cli/src/plugin_events.rs b/crates-cli/yaak-cli/src/plugin_events.rs index d59bbdd91..b4f773a10 100644 --- a/crates-cli/yaak-cli/src/plugin_events.rs +++ b/crates-cli/yaak-cli/src/plugin_events.rs @@ -13,7 +13,6 @@ use tokio::task::JoinHandle; use yaak::plugin_events::{ GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event, }; -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; @@ -24,6 +23,7 @@ use yaak_models::models::Environment; use yaak_models::queries::any_request::AnyRequest; use yaak_models::query_manager::QueryManager; use yaak_models::render::make_vars_hashmap; +use yaak_models::render::{render_grpc_request, render_http_request}; use yaak_models::util::UpdateSource; use yaak_plugins::events::{ EmptyPayload, ErrorResponse, FormInput, GetCookieValueResponse, InternalEvent, @@ -36,9 +36,14 @@ use yaak_plugins::manager::PluginManager; use yaak_plugins::template_callback::PluginTemplateCallback; use yaak_templates::{RenderOptions, TemplateCallback, render_json_value_raw}; +/// Execution contexts for sends in flight, by the id of the plugin context +/// each send was given, so plugin calls made during a send see its settings. +pub type SendContexts = Arc>>; + pub struct CliPluginEventBridge { rx_id: String, task: JoinHandle<()>, + send_contexts: SendContexts, } struct CliHostContext { @@ -49,6 +54,14 @@ struct CliHostContext { connection_manager: Arc, response_dir: PathBuf, execution_context: CliExecutionContext, + send_contexts: SendContexts, +} + +impl CliHostContext { + fn execution_context(&self, context: &PluginContext) -> CliExecutionContext { + let send_contexts = self.send_contexts.lock().expect("send contexts lock"); + send_contexts.get(&context.id).cloned().unwrap_or_else(|| self.execution_context.clone()) + } } impl CliPluginEventBridge { @@ -64,6 +77,7 @@ impl CliPluginEventBridge { let (rx_id, mut rx) = plugin_manager.subscribe("cli").await; let rx_id_for_task = rx_id.clone(); let pm = plugin_manager.clone(); + let send_contexts = SendContexts::default(); let host_context = Arc::new(CliHostContext { query_manager, blob_manager, @@ -72,6 +86,7 @@ impl CliPluginEventBridge { connection_manager, response_dir: data_dir.join("responses"), execution_context, + send_contexts: send_contexts.clone(), }); let task = tokio::spawn(async move { @@ -113,7 +128,11 @@ impl CliPluginEventBridge { pm.unsubscribe(&rx_id_for_task).await; }); - Self { rx_id, task } + Self { rx_id, task, send_contexts } + } + + pub fn send_contexts(&self) -> SendContexts { + self.send_contexts.clone() } pub async fn shutdown(self, plugin_manager: &PluginManager) { @@ -128,7 +147,7 @@ async fn build_plugin_reply( event: &InternalEvent, plugin_name: &str, ) -> Option { - let execution_context = &host_context.execution_context; + let execution_context = &host_context.execution_context(&event.context); let shared_workspace_id = event.context.workspace_id.as_deref().or(execution_context.workspace_id.as_deref()); @@ -182,6 +201,31 @@ async fn build_plugin_reply( http_request.workspace_id = workspace_id; } + let environment_id = if let Some(environment_id) = + send_http_request_request.environment_id.as_deref() + { + if shared_workspace_id.is_some_and(|id| id != http_request.workspace_id) { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "HTTP request does not belong to the selected workspace" + .to_string(), + })); + } + match host_context + .query_manager + .connect() + .get_environment_for_workspace(&http_request.workspace_id, environment_id) + { + Ok(environment) => Some(environment.id), + Err(err) => { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: err.to_string(), + })); + } + } + } else { + execution_context.environment_id.clone() + }; + let cookie_jar_id = if let Some(cookie_jar_id) = execution_context.cookie_jar_id.clone() { Some(cookie_jar_id) @@ -211,7 +255,7 @@ async fn build_plugin_reply( query_manager: &host_context.query_manager, blob_manager: &host_context.blob_manager, request: http_request, - environment_id: execution_context.environment_id.as_deref(), + environment_id: environment_id.as_deref(), update_source: UpdateSource::Plugin, cookie_jar_id, response_dir: &host_context.response_dir, @@ -1003,3 +1047,185 @@ fn prompt_label_for_base(base: &yaak_plugins::events::FormInputBase) -> String { } base.name.clone() } + +#[cfg(test)] +mod environment_tests { + use super::*; + use serde_json::json; + use tempfile::TempDir; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + use tokio::time::{Duration, timeout}; + use yaak_models::models::{EnvironmentVariable, HttpRequest, HttpRequestHeader, Workspace}; + + #[tokio::test] + async fn plugin_send_uses_override_and_fallback_and_rejects_invalid_ids_before_sending() { + let dir = TempDir::new().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (received_tx, mut received_rx) = tokio::sync::mpsc::unbounded_channel(); + let server = tokio::spawn(async move { + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + loop { + let mut chunk = [0u8; 1024]; + let n = socket.read(&mut chunk).await.unwrap(); + if n == 0 { + break; + } + request.extend_from_slice(&chunk[..n]); + if request.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + let request = String::from_utf8(request).unwrap(); + eprintln!("Local HTTP test received on {address}:\n{request}"); + received_tx.send(request).unwrap(); + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok", + ) + .await + .unwrap(); + } + }); + let (query_manager, blob_manager, _rx) = yaak_models::init_standalone( + &dir.path().join("db.sqlite"), + &dir.path().join("blobs.sqlite"), + ) + .unwrap(); + let (base, a, b, request) = query_manager + .with_tx(|db| { + let source = &UpdateSource::Background; + // Fresh databases no longer come with a workspace, so make one + let workspace = db.upsert_workspace( + &Workspace { name: "Test".into(), ..Default::default() }, + source, + )?; + let vars = |value: &str| { + vec![EnvironmentVariable { + enabled: true, + name: "marker".into(), + value: value.into(), + ..Default::default() + }] + }; + let base = db.ensure_base_environment(&workspace.id)?; + let base = db.upsert_environment( + &Environment { variables: vars("global"), ..base }, + source, + )?; + let sub = |name: &str| { + db.upsert_environment( + &Environment { + name: name.into(), + workspace_id: workspace.id.clone(), + parent_model: "environment".into(), + parent_id: Some(base.id.clone()), + variables: vars(name), + ..Default::default() + }, + source, + ) + }; + let a = sub("a")?; + let b = sub("b")?; + let request = db.upsert_http_request( + &HttpRequest { + workspace_id: workspace.id, + url: format!("http://{address}/echo"), + method: "GET".into(), + headers: vec![HttpRequestHeader { + enabled: true, + name: "X-Environment".into(), + value: "${[ marker ]}".into(), + ..Default::default() + }], + ..Default::default() + }, + source, + )?; + Ok::<_, yaak_models::error::Error>((base, a, b, request)) + }) + .unwrap(); + let plugin_dir = dir.path().join("plugins"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let plugin_manager = Arc::new( + PluginManager::new( + plugin_dir.clone(), + plugin_dir, + PathBuf::from("node"), + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"), + &query_manager, + &PluginContext::new_empty(), + false, + ) + .await + .unwrap(), + ); + let host = CliHostContext { + encryption_manager: Arc::new(EncryptionManager::new( + query_manager.clone(), + "yaak-test", + )), + query_manager, + blob_manager, + plugin_manager: plugin_manager.clone(), + connection_manager: Arc::new(HttpConnectionManager::new()), + response_dir: dir.path().join("responses"), + execution_context: CliExecutionContext { + workspace_id: Some(base.workspace_id.clone()), + environment_id: Some(a.id.clone()), + ..Default::default() + }, + send_contexts: SendContexts::default(), + }; + std::fs::create_dir_all(&host.response_dir).unwrap(); + let event = |environment_id: Option<&str>| -> InternalEvent { + let mut payload = + json!({ "type": "send_http_request_request", "httpRequest": request }); + if let Some(id) = environment_id { + payload["environmentId"] = json!(id); + } + serde_json::from_value(json!({ + "id": "test", "pluginRefId": "test", "pluginName": "test", "replyId": null, + "context": PluginContext::new_empty(), "payload": payload + })) + .unwrap() + }; + for (id, marker) in [ + (Some(b.id.as_str()), "b"), + (None, "a"), + (Some(base.id.as_str()), "global"), + ] { + let reply = + timeout(Duration::from_secs(10), build_plugin_reply(&host, &event(id), "test")) + .await + .unwrap(); + assert!( + matches!(reply, Some(InternalEventPayload::SendHttpRequestResponse(ref r)) if r.http_response.status == 200), + "{reply:?}" + ); + let received = received_rx.recv().await.unwrap().to_lowercase(); + assert!(received.contains(&format!("x-environment: {marker}\r\n")), "{received}"); + assert_eq!(host.execution_context.environment_id.as_deref(), Some(a.id.as_str())); + } + for id in ["", "ev_missing"] { + let reply = build_plugin_reply(&host, &event(Some(id)), "test").await; + assert!(matches!(reply, Some(InternalEventPayload::ErrorResponse(_))), "{reply:?}"); + } + assert!(received_rx.try_recv().is_err()); + assert_eq!( + host.query_manager + .connect() + .list_http_responses_for_request(&request.id, None) + .unwrap() + .len(), + 3 + ); + plugin_manager.terminate().await; + server.abort(); + } +} diff --git a/crates-cli/yaak-cli/tests/common/http_server.rs b/crates-cli/yaak-cli/tests/common/http_server.rs index da7976729..49a1e4113 100644 --- a/crates-cli/yaak-cli/tests/common/http_server.rs +++ b/crates-cli/yaak-cli/tests/common/http_server.rs @@ -27,6 +27,10 @@ impl TestHttpServer { while !shutdown_signal.load(Ordering::Relaxed) { match listener.accept() { Ok((mut stream, _)) => { + // macOS and the BSDs hand back accepted sockets carrying the + // listener's non-blocking flag, which leaves the read timeout + // below with nothing to govern. + let _ = stream.set_nonblocking(false); let _ = stream.set_read_timeout(Some(Duration::from_secs(1))); let mut request = Vec::new(); let mut request_buf = [0u8; 4096]; diff --git a/crates-cli/yaak-cli/tests/common/mod.rs b/crates-cli/yaak-cli/tests/common/mod.rs index 2a8749399..d500eca26 100644 --- a/crates-cli/yaak-cli/tests/common/mod.rs +++ b/crates-cli/yaak-cli/tests/common/mod.rs @@ -40,8 +40,7 @@ pub fn seed_workspace(data_dir: &Path, workspace_id: &str) { }; query_manager(data_dir) - .connect() - .upsert_workspace(&workspace, &UpdateSource::Sync) + .with_tx(|tx| tx.upsert_workspace(&workspace, &UpdateSource::Sync)) .expect("Failed to seed workspace"); } @@ -56,8 +55,7 @@ pub fn seed_request(data_dir: &Path, workspace_id: &str, request_id: &str) { }; query_manager(data_dir) - .connect() - .upsert_http_request(&request, &UpdateSource::Sync) + .with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync)) .expect("Failed to seed request"); } @@ -70,8 +68,7 @@ pub fn seed_folder(data_dir: &Path, workspace_id: &str, folder_id: &str) { }; query_manager(data_dir) - .connect() - .upsert_folder(&folder, &UpdateSource::Sync) + .with_tx(|tx| tx.upsert_folder(&folder, &UpdateSource::Sync)) .expect("Failed to seed folder"); } @@ -85,8 +82,7 @@ pub fn seed_grpc_request(data_dir: &Path, workspace_id: &str, request_id: &str) }; query_manager(data_dir) - .connect() - .upsert_grpc_request(&request, &UpdateSource::Sync) + .with_tx(|tx| tx.upsert_grpc_request(&request, &UpdateSource::Sync)) .expect("Failed to seed gRPC request"); } @@ -100,7 +96,6 @@ pub fn seed_websocket_request(data_dir: &Path, workspace_id: &str, request_id: & }; query_manager(data_dir) - .connect() - .upsert_websocket_request(&request, &UpdateSource::Sync) + .with_tx(|tx| tx.upsert_websocket_request(&request, &UpdateSource::Sync)) .expect("Failed to seed WebSocket request"); } diff --git a/crates-cli/yaak-cli/tests/import_export_commands.rs b/crates-cli/yaak-cli/tests/import_export_commands.rs index f4d05e9e0..922e04d53 100644 --- a/crates-cli/yaak-cli/tests/import_export_commands.rs +++ b/crates-cli/yaak-cli/tests/import_export_commands.rs @@ -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() { @@ -160,8 +161,7 @@ fn import_postman_environment_uses_workspace_id() { let query_manager = query_manager(data_dir); let db = query_manager.connect(); - let environments = - db.list_environments_ensure_base(&workspace_id).expect("list imported environments"); + let environments = db.list_environments(&workspace_id).expect("list imported environments"); let imported_environment = environments.iter().find(|e| e.name == "Local").expect("postman environment imported"); @@ -209,7 +209,10 @@ fn re_import_merges_into_linked_workspace() { ], ); cli_cmd(data_dir) - .args(["import", import_path.to_str().expect("import path is utf-8")]) + .args([ + "import", + import_path.to_str().expect("import path is utf-8"), + ]) .assert() .success() .stdout(contains("Imported 1 workspace, 2 HTTP requests")); @@ -257,3 +260,65 @@ fn re_import_merges_into_linked_workspace() { 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"); + drop(db); + query_manager + .with_tx(|tx| tx.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 ignored")); + + 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"); +} diff --git a/crates-cli/yaak-cli/tests/mcp_command.rs b/crates-cli/yaak-cli/tests/mcp_command.rs new file mode 100644 index 000000000..7f892e08d --- /dev/null +++ b/crates-cli/yaak-cli/tests/mcp_command.rs @@ -0,0 +1,350 @@ +mod common; + +use common::http_server::TestHttpServer; +use common::{query_manager, seed_workspace}; +use rmcp::ServiceExt; +use rmcp::model::CallToolRequestParams; +use serde_json::{Value, json}; +use std::process::Stdio; +use tempfile::TempDir; +use tokio::process::Command; +use yaak_models::models::HttpRequest; +use yaak_models::util::UpdateSource; + +#[tokio::test] +async fn mcp_serves_tools_over_stdio() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path(); + seed_workspace(data_dir, "wk_test"); + let server = TestHttpServer::spawn_ok("hello from mcp"); + let request = HttpRequest { + id: "rq_mcp".to_string(), + workspace_id: "wk_test".to_string(), + name: "MCP Send".to_string(), + method: "GET".to_string(), + url: server.url.clone(), + ..Default::default() + }; + query_manager(data_dir) + .with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync)) + .expect("Failed to seed request"); + + let mut child = Command::new(env!("CARGO_BIN_EXE_yaak")) + .arg("--data-dir") + .arg(data_dir) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let io = (child.stdout.take().unwrap(), child.stdin.take().unwrap()); + let client = ().serve(io).await.unwrap(); + + let call = async |name: &'static str, arguments: Value| { + let Value::Object(arguments) = arguments else { + unreachable!() + }; + let result = client + .call_tool(CallToolRequestParams::new(name).with_arguments(arguments)) + .await + .unwrap(); + assert_ne!(result.is_error, Some(true), "{result:?}"); + let text = result.content[0].as_text().unwrap().text.clone(); + serde_json::from_str::(&text).unwrap() + }; + + assert_eq!(client.list_all_tools().await.unwrap().len(), 15); + assert_eq!( + call("list_workspaces", json!({})).await, + json!([{ "id": "wk_test", "name": "Seed Workspace" }]) + ); + let response = call("send_http_request", json!({ "id": "rq_mcp" })).await; + assert_eq!(response["status"], 200); + assert_eq!(response["requestId"], "rq_mcp"); + + client.cancel().await.unwrap(); + assert!(child.wait().await.unwrap().success()); +} + +/// Answers each request with its `X-Environment` header and reports the +/// headers it received. +fn marker_server() -> (String, std::sync::mpsc::Receiver) { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { break }; + let mut request = Vec::new(); + let mut chunk = [0u8; 4096]; + while !request.windows(4).any(|w| w == b"\r\n\r\n") { + match stream.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => request.extend_from_slice(&chunk[..n]), + } + } + let request = String::from_utf8_lossy(&request).to_lowercase(); + let marker = request + .lines() + .find_map(|l| l.strip_prefix("x-environment:")) + .map(|v| v.trim().to_string()) + .unwrap_or_default(); + let _ = tx.send(request); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{marker}", + marker.len() + ); + let _ = stream.write_all(response.as_bytes()); + } + }); + (url, rx) +} + +#[tokio::test] +async fn mcp_send_settings_reach_template_functions() { + use yaak_models::models::{ + Cookie, CookieDomain, CookieExpires, CookieJar, Environment, EnvironmentVariable, + HttpRequestHeader, + }; + + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path(); + seed_workspace(data_dir, "wk_test"); + let (url, received) = marker_server(); + let header = |name: &str, value: &str| HttpRequestHeader { + enabled: true, + name: name.into(), + value: value.into(), + ..Default::default() + }; + let override_id = query_manager(data_dir) + .with_tx(|db| { + let source = &UpdateSource::Sync; + let vars = |value: &str| { + vec![EnvironmentVariable { + enabled: true, + name: "marker".into(), + value: value.into(), + ..Default::default() + }] + }; + let base = db.ensure_base_environment("wk_test")?; + let base = + db.upsert_environment(&Environment { variables: vars("global"), ..base }, source)?; + let chosen = db.upsert_environment( + &Environment { + name: "chosen".into(), + workspace_id: "wk_test".into(), + parent_model: "environment".into(), + parent_id: Some(base.id.clone()), + variables: vars("chosen"), + ..Default::default() + }, + source, + )?; + db.upsert_cookie_jar( + &CookieJar { + workspace_id: "wk_test".into(), + name: "Default".into(), + cookies: vec![Cookie { + name: "session".into(), + value: "from-jar".into(), + domain: CookieDomain::HostOnly("127.0.0.1".into()), + expires: CookieExpires::SessionEnd, + path: "/".into(), + secure: false, + http_only: false, + same_site: None, + }], + ..Default::default() + }, + source, + )?; + db.upsert_http_request( + &HttpRequest { + id: "rq_inner".into(), + workspace_id: "wk_test".into(), + method: "GET".into(), + url: format!("{url}/inner"), + headers: vec![header("X-Environment", "${[ marker ]}")], + ..Default::default() + }, + source, + )?; + db.upsert_http_request( + &HttpRequest { + id: "rq_outer".into(), + workspace_id: "wk_test".into(), + method: "GET".into(), + url: format!("{url}/outer"), + headers: vec![ + header( + "X-Nested", + "${[ response.body.raw(request='rq_inner', behavior='always') ]}", + ), + header("X-Cookie", "${[ cookie.value(name='session') ]}"), + ], + ..Default::default() + }, + source, + )?; + Ok::<_, yaak_models::error::Error>(chosen.id) + }) + .unwrap(); + + let mut child = Command::new(env!("CARGO_BIN_EXE_yaak")) + .arg("--data-dir") + .arg(data_dir) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let io = (child.stdout.take().unwrap(), child.stdin.take().unwrap()); + let client = ().serve(io).await.unwrap(); + + let Value::Object(arguments) = json!({ "id": "rq_outer", "environmentId": override_id }) else { + unreachable!() + }; + let result = client + .call_tool(CallToolRequestParams::new("send_http_request").with_arguments(arguments)) + .await + .unwrap(); + assert_ne!(result.is_error, Some(true), "{result:?}"); + + let outer = received.iter().find(|r| r.starts_with("get /outer")).unwrap(); + assert!(outer.contains("x-nested: chosen\r\n"), "{outer}"); + assert!(outer.contains("x-cookie: from-jar\r\n"), "{outer}"); + + client.cancel().await.unwrap(); + assert!(child.wait().await.unwrap().success()); +} + +/// Spawns `yaak mcp --http` with `args` and returns the child, the URL it +/// printed, and the token it printed or was given. +async fn spawn_http(data_dir: &std::path::Path, args: &[&str]) -> (tokio::process::Child, String, String) { + use tokio::io::AsyncBufReadExt; + + let mut child = Command::new(env!("CARGO_BIN_EXE_yaak")) + .arg("--data-dir") + .arg(data_dir) + .args(["mcp", "--http", "--port", "0"]) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let mut stderr = tokio::io::BufReader::new(child.stderr.take().unwrap()).lines(); + let mut url = None; + let mut token = None; + while url.is_none() || token.is_none() { + let line = stderr.next_line().await.unwrap().expect("server exited before announcing"); + if let Some(rest) = line.strip_prefix("Yaak MCP server listening on ") { + url = Some(rest.to_string()); + } else if let Some(rest) = line.strip_prefix("Token: ") { + token = Some(rest.to_string()); + } + } + (child, url.unwrap(), token.unwrap()) +} + +/// Posts one JSON-RPC message, with the session id when there is one, and +/// returns the response and its body parsed out of either JSON or SSE. +async fn post( + client: &reqwest::Client, + url: &str, + auth: Option<&str>, + session: Option<&str>, + body: Value, +) -> (reqwest::StatusCode, reqwest::header::HeaderMap, Option) { + let mut request = client + .post(url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(body.to_string()); + if let Some(auth) = auth { + request = request.header("Authorization", auth); + } + if let Some(session) = session { + request = request.header("Mcp-Session-Id", session); + } + let response = request.send().await.unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let text = response.text().await.unwrap(); + let message = text + .lines() + .filter_map(|l| l.strip_prefix("data:")) + .last() + .map(str::trim) + .unwrap_or(text.trim()); + (status, headers, serde_json::from_str(message).ok()) +} + +#[tokio::test] +async fn mcp_http_requires_the_token() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path(); + seed_workspace(data_dir, "wk_test"); + let (mut child, url, token) = spawn_http(data_dir, &["--token", "s3cret"]).await; + assert_eq!(token, "s3cret"); + assert!(url.starts_with("http://127.0.0.1:"), "{url}"); + assert!(url.ends_with("/mcp"), "{url}"); + let client = reqwest::Client::new(); + let initialize = json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "1.0" } } + }); + + for auth in [None, Some("Bearer wrong")] { + let (status, headers, _) = post(&client, &url, auth, None, initialize.clone()).await; + assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED, "{auth:?}"); + assert_eq!(headers["www-authenticate"], "Bearer"); + } + + let auth = Some("Bearer s3cret"); + let (status, headers, body) = post(&client, &url, auth, None, initialize).await; + assert_eq!(status, reqwest::StatusCode::OK); + let session = headers["mcp-session-id"].to_str().unwrap().to_string(); + assert_eq!(body.unwrap()["result"]["serverInfo"]["name"], "yaak-mcp-server"); + + let initialized = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }); + let (status, _, _) = post(&client, &url, auth, Some(&session), initialized).await; + assert_eq!(status, reqwest::StatusCode::ACCEPTED); + + let list = json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }); + let (status, _, body) = post(&client, &url, auth, Some(&session), list).await; + assert_eq!(status, reqwest::StatusCode::OK); + let tools = body.unwrap()["result"]["tools"].as_array().unwrap().len(); + assert_eq!(tools, 15); + + child.kill().await.unwrap(); +} + +#[tokio::test] +async fn mcp_http_generates_a_token() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path(); + seed_workspace(data_dir, "wk_test"); + let (mut child, url, token) = spawn_http(data_dir, &[]).await; + assert_eq!(token.len(), 64, "{token}"); + assert!(token.chars().all(|c| c.is_ascii_hexdigit()), "{token}"); + + let client = reqwest::Client::new(); + let initialize = json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "1.0" } } + }); + let (status, _, _) = post(&client, &url, None, None, initialize.clone()).await; + assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED); + let (status, _, _) = + post(&client, &url, Some(&format!("Bearer {token}")), None, initialize).await; + assert_eq!(status, reqwest::StatusCode::OK); + + child.kill().await.unwrap(); +} diff --git a/crates-cli/yaak-cli/tests/plugin_filter.rs b/crates-cli/yaak-cli/tests/plugin_filter.rs new file mode 100644 index 000000000..077baf11c --- /dev/null +++ b/crates-cli/yaak-cli/tests/plugin_filter.rs @@ -0,0 +1,85 @@ +mod common; + +use common::http_server::TestHttpServer; +use common::{cli_cmd, query_manager, seed_workspace}; +use predicates::str::contains; +use std::fs; +use tempfile::TempDir; +use yaak_models::models::{HttpRequest, HttpRequestHeader}; +use yaak_models::util::UpdateSource; + +/// A template function that runs `ctx.filter` and returns the host's reply as JSON. +const PROBE_PLUGIN: &str = r#"exports.plugin = { + templateFunctions: [{ + name: "filter_probe", + args: [ + { type: "text", name: "language" }, + { type: "text", name: "text" }, + { type: "text", name: "query" }, + ], + async onRender(ctx, args) { + const { language, text, query } = args.values; + const result = language === "xpath" + ? await ctx.filter.xPath(text, query) + : await ctx.filter.jsonPath(text, query); + return JSON.stringify(result); + }, + }], +};"#; + +/// The plugin runtime, the CLI's plugin host, and yaak-filter, end to end: the +/// rendered header values are what the host replied to the plugin. +#[test] +fn plugins_filter_text_through_the_host() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path(); + + // Bundled plugins are extracted next to this on launch; extras are kept. + let probe_dir = data_dir.join("vendored-plugins").join("filter-probe"); + fs::create_dir_all(probe_dir.join("build")).expect("Failed to create probe plugin dir"); + fs::write(probe_dir.join("package.json"), r#"{"name":"@yaak/filter-probe","version":"0.0.0"}"#) + .expect("Failed to write probe package.json"); + fs::write(probe_dir.join("build").join("index.js"), PROBE_PLUGIN) + .expect("Failed to write probe plugin"); + + seed_workspace(data_dir, "wk_test"); + let server = TestHttpServer::spawn_ok("ok"); + let header = |name: &str, value: &str| HttpRequestHeader { + enabled: true, + name: name.to_string(), + value: value.to_string(), + id: None, + }; + let request = HttpRequest { + id: "rq_filter".to_string(), + workspace_id: "wk_test".to_string(), + method: "GET".to_string(), + url: server.url.clone(), + headers: vec![ + header( + "x-json", + r#"${[ filter_probe(language='jsonpath', text='{"a":[1,2]}', query='$.a[1]') ]}"#, + ), + header( + "x-xml", + "${[ filter_probe(language='xpath', text='x', query='//b') ]}", + ), + header( + "x-bad", + "${[ filter_probe(language='jsonpath', text='{}', query='$[?(@ ==)]') ]}", + ), + ], + ..Default::default() + }; + query_manager(data_dir) + .with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync)) + .expect("Failed to seed request"); + + cli_cmd(data_dir) + .args(["--verbose", "request", "send", "rq_filter"]) + .assert() + .success() + .stdout(contains(r#"> x-json: {"content":"[\n 2\n]"}"#)) + .stdout(contains(r#"> x-xml: {"content":"x"}"#)) + .stdout(contains(r#"> x-bad: {"content":"","error":"Invalid filter: "#)); +} diff --git a/crates-cli/yaak-cli/tests/send_commands.rs b/crates-cli/yaak-cli/tests/send_commands.rs index 6ed033823..2ff09b4bf 100644 --- a/crates-cli/yaak-cli/tests/send_commands.rs +++ b/crates-cli/yaak-cli/tests/send_commands.rs @@ -25,8 +25,7 @@ fn top_level_send_folder_sends_http_requests_and_prints_summary() { ..Default::default() }; query_manager(data_dir) - .connect() - .upsert_http_request(&request, &UpdateSource::Sync) + .with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync)) .expect("Failed to seed folder request"); cli_cmd(data_dir) diff --git a/crates-proxy/yaak-proxy-lib/Cargo.toml b/crates-proxy/yaak-proxy-lib/Cargo.toml index 0eb2dd8ca..05845b2c7 100644 --- a/crates-proxy/yaak-proxy-lib/Cargo.toml +++ b/crates-proxy/yaak-proxy-lib/Cargo.toml @@ -6,16 +6,75 @@ authors = ["Gregory Schier"] publish = false [dependencies] +base64 = "0.23.1" +bytes = "1" chrono = { workspace = true, features = ["serde"] } +encoding_rs = "0.8" log = { workspace = true } +http-body-util = "0.1" +hyper = { version = "1", features = ["http1", "server"] } +hyper-util = { version = "0.1", features = ["server-graceful", "tokio"] } include_dir = "0.7" r2d2 = "0.8.10" r2d2_sqlite = "0.32" -rusqlite = { version = "0.38", features = ["bundled", "chrono"] } +reqwest = { workspace = true, features = ["json"] } +rusqlite = { version = "0.38", features = ["bundled", "chrono", "functions"] } sea-query = { version = "1.0", features = ["with-chrono", "attr"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +tokio = { workspace = true, features = ["io-util", "macros", "net", "process", "rt-multi-thread"] } ts-rs = { workspace = true, features = ["chrono-impl"] } +url = "2" +yaak-common = { workspace = true } yaak-database = { workspace = true } +yaak-filter = { workspace = true } +yaak-http = { workspace = true } +yaak-lifecycle = { workspace = true } +yaak-models = { workspace = true } yaak-proxy = { workspace = true } +yaak-proxy-helper = { workspace = true, features = ["ts"] } yaak-rpc = { workspace = true } +yaak-templates = { workspace = true } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = { version = "0.3.2", default-features = false, features = [ + "std", + "objc2-core-foundation", + "NSBitmapImageRep", + "NSGraphics", + "NSGraphicsContext", + "NSImage", + "NSImageRep", + "NSWorkspace", +] } +objc2-foundation = { version = "0.3.2", default-features = false, features = [ + "std", + "NSData", + "NSDictionary", + "NSError", + "NSGeometry", + "NSString", +] } +objc2-service-management = { version = "0.3.2", default-features = false, features = [ + "std", + "objc2", + "objc2-foundation", + "SMAppService", +] } +plist = "1.7" + +[dev-dependencies] +base64 = "0.23.1" +bytes = "1" +flate2 = "1" +http-body-util = "0.1" +hyper = { version = "1", features = ["http1", "server"] } +hyper-util = { version = "0.1", features = ["tokio"] } +reqwest = { workspace = true } +tempfile = "3" +tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "time"] } +yaak-proxy-helper = { workspace = true, features = ["test-support"] } diff --git a/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts b/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts index ba452b1cf..153dd80f9 100644 --- a/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts +++ b/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts @@ -1,8 +1,74 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ModelChangeEvent } from "./ModelChangeEvent"; -export type HttpExchange = { id: string, createdAt: string, updatedAt: string, url: string, method: string, reqHeaders: Array, reqBody: Array | null, resStatus: number | null, resHeaders: Array, resBody: Array | null, error: string | null, }; +/** + * One proxied request and its response. The bodies are in `blobs.sqlite`, not the row. + */ +export type Flow = { id: string, createdAt: string, updatedAt: string, startedAt: string, method: string, url: string, +/** + * Version spoken by the client. + */ +clientHttpVersion: string, +/** + * Version spoken with the upstream server. + */ +upstreamHttpVersion: string | null, remoteAddr: string | null, status: number | null, +/** + * The response's Content-Type. + */ +contentType: string | null, requestHeaders: Array, responseHeaders: Array, +/** + * Bytes of the request body on the wire, captured or not. + */ +requestSize: number, +/** + * Bytes of the response body on the wire, captured or not. + */ +responseSize: number, +/** + * Only the first `capture_body_limit` bytes of the body are stored. + */ +requestTruncated: boolean, +/** + * Only the first `capture_body_limit` bytes of the body are stored. + */ +responseTruncated: boolean, +/** + * Milliseconds from `started_at` to the response headers. + */ +elapsedHeadersMs: number | null, +/** + * Milliseconds from `started_at` to the end of the response. + */ +elapsedMs: number | null, error: string | null, +/** + * The client gave up before the response ended, by resetting its HTTP/2 stream or closing + * the connection. Not an error. + */ +cancelled: boolean, +/** + * The id of the Yaak request this flow was last saved as. + */ +savedRequestId: string | null, +/** + * The process that opened the client connection, where the platform can tell (macOS). + */ +clientPid: number | null, +/** + * Its app's name, or its executable's outside an app: "Google Chrome", "curl". + */ +clientName: string | null, +/** + * Its app's bundle id. + */ +clientBundleId: string | null, +/** + * Its app bundle's path, or its executable's outside an app. + */ +clientPath: string | null, }; -export type ModelPayload = { model: HttpExchange, change: ModelChangeEvent, }; +export type FlowBody = "request" | "response"; + +export type ModelPayload = { model: Flow, change: ModelChangeEvent, }; export type ProxyHeader = { name: string, value: string, }; diff --git a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts index 850717c35..629597ae7 100644 --- a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts +++ b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts @@ -1,28 +1,356 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { HttpExchange, ModelPayload } from "./gen_models"; +import type { Flow, FlowBody, ModelPayload } from "./gen_models"; export type ActionInvocation = { "scope": "global", action: GlobalAction, }; export type ActionMetadata = { label: string, defaultHotkey: string | null, }; +export type AppIconRequest = { +/** + * A flow's `clientPath`. + */ +path: string, }; + +export type CaStatus = { +/** + * macOS trusts this app's CA, so HTTPS flows can be decrypted. + */ +trusted: boolean, +/** + * A certificate named "Yaak Proxy CA" is in a keychain: this one, or an older one. + */ +installed: boolean, certPath: string, }; + +export type CaStatusRequest = Record; + +export type CaTrustRequest = Record; + +export type ClearFlowsRequest = Record; + +/** + * The app behind a client connection. + */ +export type ClientApp = { pid: number, +/** + * The app's name ("Google Chrome"), or the executable's for a process outside an app ("curl"). + */ +name: string, +/** + * The outermost `.app` bundle's CFBundleIdentifier, if any. + */ +bundleId: string | null, +/** + * The outermost `.app` bundle's path, or the executable's path when there is no bundle. + */ +path: string, }; + +export type DropReason = "hop_by_hop" | "set_by_sending" | "body_decoded"; + +export type DroppedHeader = { name: string, value: string, reason: DropReason, }; + +export type ExportHarRequest = { +/** + * The file to write. + */ +path: string, +/** + * These flows, or else every flow the filter covers. + */ +ids?: Array, filter?: FlowFilter, }; + +export type FilterBodyRequest = { flowId: string, which: FlowBody, +/** + * JSONPath, or XPath for an XML or HTML body. + */ +expression: string, +/** + * The language the viewer detected from the body. Without it, the Content-Type decides. + */ +language?: FilterLanguage, }; + +export type FilterBodyResponse = { content: string, error: string | null, }; + +export type FilterLanguage = "jsonpath" | "xpath"; + +/** + * Which flows a listing or a HAR export covers. Every field narrows it; none is all of them. + */ +export type FlowFilter = { +/** + * Search terms, any case, each of which must match the URL (host, path or query), the + * method, the status (`404`, `40`, `4xx`) or the content type: `stripe post 4xx`. + */ +q?: string, +/** + * Part of the host, any case: `stripe` matches `api.stripe.com`. + */ +host?: string, +/** + * Only flows under one of these hosts or paths, as the app's sidebar names them: + * `api.stripe.com` or `api.stripe.com/v1/charges`, with any port after the host. + */ +under?: Array, +/** + * Only flows from one of these apps, by bundle id, or by path for a process outside an + * app bundle. + */ +app?: Array, +/** + * The method, any case. + */ +method?: string, +/** + * A status (`404`), a class of them (`4xx`), or `error` for flows that failed. + */ +status?: string, +/** + * At most this many flows, the newest. + */ +limit?: number, +/** + * Only flows recorded after the flow with this id, for following new traffic. Flows are + * recorded when they end, so a slow one that started earlier still comes through. + */ +after?: string, +/** + * Only flows older than the flow with this id, by start and then id, for paging back + * through older traffic. + */ +before?: string, }; + +export type FlowsClearedPayload = Record; + +export type FolderSummary = { id: string, name: string, +/** + * The folder this one is in, if any. + */ +folderId: string | null, }; + +export type FormatBodyRequest = { text: string, }; + +export type GetFlowBodyRequest = { flowId: string, which: FlowBody, }; + +export type GetFlowBodyResponse = { +/** + * The body decoded with its Content-Encoding, at most the capture limit of it, in base64. + * When it fails to decode, the bytes as they were on the wire instead. + */ +data: string, +/** + * The body is incomplete: cut on the wire, or decoded past the capture limit. + */ +truncated: boolean, +/** + * Bytes stored for the body, as they were on the wire. + */ +storedSize: number, +/** + * Why the body failed to decode, in which case `data` is as it was on the wire. + */ +decodeError?: string, }; + export type GetProxyStateRequest = Record; -export type GetProxyStateResponse = { state: ProxyState, }; +export type GetProxyStateResponse = { state: ProxyState, +/** + * The port the engine is bound to, while it runs. + */ +port: number | null, +/** + * The port the engine starts on. + */ +configuredPort: number, +/** + * Flows are stored. Paused, traffic still passes through, unrecorded. + */ +recording: boolean, +/** + * The system proxy turns on as the engine starts. + */ +systemProxyAtLaunch: boolean, +/** + * The HTTP versions offered to clients. + */ +httpVersion: HttpVersion, +/** + * Why the engine last failed to start, until it starts. + */ +startError: string | null, +/** + * Another process that owns the engine, such as the CLI daemon. While one does, this app + * cannot start its own, and new flows reach it only through the database. + */ +otherOwner: OtherOwner | null, }; -export type GlobalAction = "proxy_start" | "proxy_stop"; +export type GlobalAction = "recording_toggle" | "flows_clear"; + +export type HelperRegisterRequest = Record; + +export type HelperState = "unavailable" | "not_registered" | "requires_approval" | "enabled" | "not_found"; + +export type HelperUnregisterRequest = Record; + +/** + * The HTTP versions offered to clients in intercepted HTTPS. Upstream, the engine speaks + * whichever the server picks either way. + */ +export type HttpVersion = "auto" | "http1Only"; export type ListActionsRequest = Record; export type ListActionsResponse = { actions: Array<[ActionInvocation, ActionMetadata]>, }; -export type ListModelsRequest = Record; +export type ListFlowsResponse = { +/** + * Newest first. + */ +flows: Array, +/** + * Flows in the database, whatever the filter. + */ +total: number, +/** + * Flows the filter covers, whatever the limit, when it narrows to fewer than all of them. + */ +matching: number | null, }; -export type ListModelsResponse = { httpExchanges: Array, }; +export type ListWorkspacesRequest = Record; + +export type ListWorkspacesResponse = { +/** + * Yaak has run on this machine. Without it there is nothing to save into. + */ +yaakFound: boolean, workspaces: Array, }; + +export type OtherOwner = { owner: Owner, pid: number, startedAt: string, +/** + * The engine's port while it runs. + */ +proxyPort: number | null, }; + +/** + * The process that runs the engine. + */ +export type Owner = "app" | "cli"; + +export type PreviewSaveRequest = { flowId: string, }; + +export type ProxySetting = { enabled: boolean, server: string, port: number, }; export type ProxyState = "running" | "stopped"; export type ProxyStatePayload = { state: ProxyState, }; -export type RpcEventSchema = { model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, }; +export type RpcEventSchema = { flows_cleared: FlowsClearedPayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, }; -export type RpcSchema = { execute_action: [ActionInvocation, boolean], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_models: [ListModelsRequest, ListModelsResponse], }; +export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], helper_register: [HelperRegisterRequest, SystemProxyStatus], helper_unregister: [HelperUnregisterRequest, SystemProxyStatus], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], }; + +export type SaveFlowBodyRequest = { flowId: string, which: FlowBody, +/** + * The file to write. + */ +path: string, }; + +export type SaveFlowBodyResponse = { +/** + * Bytes written. + */ +size: number, +/** + * What was written is incomplete, as `GetFlowBodyResponse.truncated` is. + */ +truncated: boolean, +/** + * The body failed to decode, so the file has it as it was on the wire. + */ +raw: boolean, }; + +export type SaveFlowToYaakRequest = { flowId: string, workspaceId: string, folderId?: string, +/** + * None or blank leaves the request unnamed, and Yaak shows its URL. + */ +name?: string, }; + +/** + * What saving a flow as a Yaak request writes, for the save dialog. + */ +export type SavePreview = { method: string, url: string, +/** + * The URL as saved, its query split out into parameters, which Yaak shows for an unnamed + * request. + */ +requestUrl: string, +/** + * The saved body type, which Yaak shows for an unnamed GraphQL request with no URL. + */ +bodyType: string | null, +/** + * Headers Yaak sets itself or that only meant something to the proxy. + */ +droppedHeaders: Array, +/** + * Headers saved as captured, Authorization among them. + */ +keptHeaders: number, +/** + * The body as it is saved: "JSON", "form URL-encoded with 7 fields". None without a body. + */ +body: string | null, +/** + * What does not carry over exactly, one sentence each. + */ +warnings: Array, +/** + * An Authorization header is saved in plain text. + */ +authorization: boolean, }; + +export type SavedFlow = { requestId: string, name: string, }; + +export type ServiceProxy = { +/** + * The network service, as System Settings names it: "Wi-Fi", "USB 10/100/1000 LAN". + */ +name: string, +/** + * What stays the same when the service is renamed: its device ("en0"), or its hardware + * port when it has none. Absent when `networksetup` lists neither. + */ +id?: string, http: ProxySetting, https: ProxySetting, }; + +export type SetHttpVersionRequest = { httpVersion: HttpVersion, }; + +export type SetRecordingRequest = { enabled: boolean, }; + +export type SetSystemProxyAtLaunchRequest = { enabled: boolean, }; + +/** + * How a change gets administrator rights. + */ +export type SystemProxyBackend = "helper" | "admin_prompt" | "sudo"; + +export type SystemProxySetRequest = { enabled: boolean, }; + +export type SystemProxyStatus = { +/** + * Every enabled network service sends HTTP and HTTPS to `127.0.0.1:`. + */ +enabled: boolean, +/** + * The port checked against. + */ +port: number, services: Array, backend: SystemProxyBackend, +/** + * The privileged helper's registration, which decides the backend. + */ +helper: HelperState, +/** + * This platform has a system proxy the app can set. + */ +supported: boolean, }; + +export type SystemProxyStatusRequest = Record; + +export type TakeOverRequest = Record; + +export type WorkspaceSummary = { id: string, name: string, folders: Array, }; diff --git a/crates-proxy/yaak-proxy-lib/migrations/00000002_flows.sql b/crates-proxy/yaak-proxy-lib/migrations/00000002_flows.sql new file mode 100644 index 000000000..ba31ed55e --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/migrations/00000002_flows.sql @@ -0,0 +1,27 @@ +DROP TABLE http_exchanges; + +CREATE TABLE flows +( + id TEXT NOT NULL PRIMARY KEY, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + started_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + method TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + client_http_version TEXT NOT NULL DEFAULT '', + upstream_http_version TEXT, + remote_addr TEXT, + status INTEGER, + content_type TEXT, + request_headers TEXT NOT NULL DEFAULT '[]', + response_headers TEXT NOT NULL DEFAULT '[]', + request_size INTEGER NOT NULL DEFAULT 0, + response_size INTEGER NOT NULL DEFAULT 0, + request_truncated BOOLEAN NOT NULL DEFAULT FALSE, + response_truncated BOOLEAN NOT NULL DEFAULT FALSE, + elapsed_headers_ms INTEGER, + elapsed_ms INTEGER, + error TEXT +); + +CREATE INDEX idx_flows_started_at ON flows (started_at); diff --git a/crates-proxy/yaak-proxy-lib/migrations/00000003_flow_saved_request.sql b/crates-proxy/yaak-proxy-lib/migrations/00000003_flow_saved_request.sql new file mode 100644 index 000000000..ea584f355 --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/migrations/00000003_flow_saved_request.sql @@ -0,0 +1,2 @@ +-- The Yaak request a flow was last saved as. +ALTER TABLE flows ADD COLUMN saved_request_id TEXT; diff --git a/crates-proxy/yaak-proxy-lib/migrations/00000004_flow_client.sql b/crates-proxy/yaak-proxy-lib/migrations/00000004_flow_client.sql new file mode 100644 index 000000000..2ca9dcfae --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/migrations/00000004_flow_client.sql @@ -0,0 +1,9 @@ +-- The app that opened the flow's client connection, where the platform can tell. +ALTER TABLE flows ADD COLUMN client_pid INTEGER; +ALTER TABLE flows ADD COLUMN client_name TEXT; +ALTER TABLE flows ADD COLUMN client_bundle_id TEXT; +ALTER TABLE flows ADD COLUMN client_path TEXT; + +-- Flows list and page newest first, by start and then id. +DROP INDEX idx_flows_started_at; +CREATE INDEX idx_flows_started_at_id ON flows (started_at, id); diff --git a/crates-proxy/yaak-proxy-lib/migrations/00000005_flow_cancelled.sql b/crates-proxy/yaak-proxy-lib/migrations/00000005_flow_cancelled.sql new file mode 100644 index 000000000..5f9e799a6 --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/migrations/00000005_flow_cancelled.sql @@ -0,0 +1,2 @@ +-- The client gave up on the flow, by resetting its HTTP/2 stream or closing the connection. +ALTER TABLE flows ADD COLUMN cancelled BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/crates-proxy/yaak-proxy-lib/src/actions.rs b/crates-proxy/yaak-proxy-lib/src/actions.rs index cf2084eb1..4aba47644 100644 --- a/crates-proxy/yaak-proxy-lib/src/actions.rs +++ b/crates-proxy/yaak-proxy-lib/src/actions.rs @@ -5,8 +5,9 @@ use ts_rs::TS; #[serde(rename_all = "snake_case")] #[ts(export, export_to = "gen_rpc.ts")] pub enum GlobalAction { - ProxyStart, - ProxyStop, + /// Pauses or resumes storing flows. + RecordingToggle, + FlowsClear, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] @@ -32,17 +33,18 @@ fn default_hotkey(mac: &str, other: &str) -> Option { pub fn all_global_actions() -> Vec<(ActionInvocation, ActionMetadata)> { vec![ ( - ActionInvocation::Global { action: GlobalAction::ProxyStart }, + ActionInvocation::Global { action: GlobalAction::RecordingToggle }, ActionMetadata { - label: "Start Proxy".into(), - default_hotkey: default_hotkey("Meta+Shift+P", "Ctrl+Shift+P"), + label: "Pause or Resume Recording".into(), + // Not Cmd+Shift+R, which reloads the window in development builds. + default_hotkey: default_hotkey("Meta+R", "Ctrl+Shift+R"), }, ), ( - ActionInvocation::Global { action: GlobalAction::ProxyStop }, + ActionInvocation::Global { action: GlobalAction::FlowsClear }, ActionMetadata { - label: "Stop Proxy".into(), - default_hotkey: default_hotkey("Meta+Shift+S", "Ctrl+Shift+S"), + label: "Clear Flows".into(), + default_hotkey: default_hotkey("Meta+K", "Ctrl+K"), }, ), ] diff --git a/crates-proxy/yaak-proxy-lib/src/attribution.rs b/crates-proxy/yaak-proxy-lib/src/attribution.rs new file mode 100644 index 000000000..8e8ae4b3d --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/src/attribution.rs @@ -0,0 +1,1051 @@ +//! Which app opened each client connection. The client's end of a connection to the engine is a +//! loopback TCP socket whose local port is the client's ephemeral port and whose foreign port is +//! the engine's, so the app behind it is the process holding that socket. On macOS one worker +//! thread finds it through `libproc`, once per connection, checking the processes that opened +//! recent connections before scanning them all. Other platforms report nothing. + +use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; +use std::time::Duration; +use ts_rs::TS; + +/// The app behind a client connection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct ClientApp { + pub pid: i32, + /// The app's name ("Google Chrome"), or the executable's for a process outside an app ("curl"). + pub name: String, + /// The outermost `.app` bundle's CFBundleIdentifier, if any. + pub bundle_id: Option, + /// The outermost `.app` bundle's path, or the executable's path when there is no bundle. + pub path: String, +} + +/// Finds the app behind each client connection, once per connection, off the caller's thread. +/// Cheap to clone. +#[derive(Clone)] +pub struct Attribution { + lookups: Option, +} + +impl Attribution { + /// For an engine listening on `proxy_port`. On platforms without a lookup this does nothing + /// and spawns nothing. + pub fn new(proxy_port: u16) -> Self { + Self { lookups: imp::Lookups::start(proxy_port) } + } + + /// Starts finding the app behind `connection`, whose client end is `client_addr`. Called on + /// each request; only the first call per connection does anything. Returns immediately. + pub fn begin(&self, connection: u64, client_addr: SocketAddr) { + if let Some(lookups) = &self.lookups { + lookups.begin(connection, client_addr); + } + } + + /// The app behind `connection`, waiting up to `wait` for a lookup still in progress. None + /// when unknown, not found, or not supported. + pub fn get(&self, connection: u64, wait: Duration) -> Option { + self.lookups.as_ref().and_then(|l| l.get(connection, wait)) + } +} + +/// The icon of the app or executable at `path` (a ClientApp's `path`) as a PNG, 64 by 64 +/// pixels, cached in memory by path. `Ok(None)` on platforms without icons. Blocks. +#[cfg(target_os = "macos")] +pub fn app_icon_png(path: &str) -> Result>, String> { + icon::png(path).map(Some) +} + +#[cfg(not(target_os = "macos"))] +pub fn app_icon_png(_path: &str) -> Result>, String> { + Ok(None) +} + +/// A map that keeps only its `cap` most recently inserted keys. +#[cfg(target_os = "macos")] +struct Bounded { + map: std::collections::HashMap, + order: std::collections::VecDeque, + cap: usize, +} + +#[cfg(target_os = "macos")] +impl Bounded { + fn new(cap: usize) -> Self { + Self { map: Default::default(), order: Default::default(), cap } + } + + fn get(&self, key: &Q) -> Option<&V> + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + self.map.get(key) + } + + fn get_mut(&mut self, key: &K) -> Option<&mut V> { + self.map.get_mut(key) + } + + fn insert(&mut self, key: K, value: V) { + if self.map.insert(key.clone(), value).is_none() { + self.order.push_back(key); + } + while self.order.len() > self.cap { + if let Some(oldest) = self.order.pop_front() { + self.map.remove(&oldest); + } + } + } +} + +#[cfg(not(target_os = "macos"))] +mod imp { + use super::ClientApp; + use std::net::SocketAddr; + use std::time::Duration; + + /// No lookup on this platform. + #[derive(Clone)] + pub enum Lookups {} + + impl Lookups { + pub fn start(_proxy_port: u16) -> Option { + None + } + + pub fn begin(&self, _connection: u64, _client_addr: SocketAddr) { + match *self {} + } + + pub fn get(&self, _connection: u64, _wait: Duration) -> Option { + match *self {} + } + } +} + +#[cfg(target_os = "macos")] +mod imp { + use super::{Bounded, ClientApp, proc}; + use log::warn; + use std::collections::{HashMap, HashSet}; + use std::net::SocketAddr; + use std::path::Path; + use std::sync::mpsc::{Receiver, Sender, channel}; + use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError, Weak}; + use std::time::{Duration, Instant}; + + /// Connections whose lookups are remembered, the most recently begun. + const CONNECTIONS: usize = 4096; + /// Pids that recently owned client sockets, checked before a full scan. + const RECENT_PIDS: usize = 256; + /// Apps remembered by pid and start time. + const APPS: usize = 512; + + /// The lookup state shared by every clone of an `Attribution`, and its worker's sender. + /// The worker stops once the last sender is dropped. + #[derive(Clone)] + pub struct Lookups { + shared: Arc, + jobs: Sender, + } + + struct Shared { + connections: Mutex>, + done: Condvar, + } + + enum Slot { + Pending, + Done(Option), + } + + struct Job { + connection: u64, + client_port: u16, + } + + impl Shared { + fn lock(&self) -> MutexGuard<'_, Bounded> { + self.connections.lock().unwrap_or_else(PoisonError::into_inner) + } + } + + impl Lookups { + /// Spawns the worker, or returns None (and logs) when the thread cannot start. + pub fn start(proxy_port: u16) -> Option { + let shared = Arc::new(Shared { + connections: Mutex::new(Bounded::new(CONNECTIONS)), + done: Condvar::new(), + }); + let (jobs, rx) = channel(); + let weak = Arc::downgrade(&shared); + let spawned = std::thread::Builder::new() + .name("yaak-proxy-attribution".into()) + .spawn(move || work(weak, rx, proxy_port)); + match spawned { + Ok(_) => Some(Self { shared, jobs }), + Err(e) => { + warn!("Failed to start the client app lookup thread: {e}"); + None + } + } + } + + pub fn begin(&self, connection: u64, client_addr: SocketAddr) { + let mut connections = self.shared.lock(); + if connections.get(&connection).is_some() { + return; + } + // A client on another host has no process here. + if !is_loopback(client_addr) { + connections.insert(connection, Slot::Done(None)); + return; + } + connections.insert(connection, Slot::Pending); + let job = Job { connection, client_port: client_addr.port() }; + // The worker is gone only if it panicked; nobody should wait on it then. + if self.jobs.send(job).is_err() { + connections.insert(connection, Slot::Done(None)); + } + } + + pub fn get(&self, connection: u64, wait: Duration) -> Option { + let deadline = Instant::now() + wait; + let mut connections = self.shared.lock(); + loop { + match connections.get(&connection)? { + Slot::Done(app) => return app.clone(), + Slot::Pending => {} + } + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return None; + } + connections = match self.shared.done.wait_timeout(connections, left) { + Ok((guard, _)) => guard, + Err(e) => e.into_inner().0, + }; + } + } + } + + fn is_loopback(addr: SocketAddr) -> bool { + match addr { + SocketAddr::V4(a) => a.ip().is_loopback(), + SocketAddr::V6(a) => { + a.ip().is_loopback() || a.ip().to_ipv4_mapped().is_some_and(|v4| v4.is_loopback()) + } + } + } + + /// Resolves every job queued by the time it wakes in one pass, so a burst of short-lived + /// clients costs one scan rather than one each, and their sockets are still open for it. + fn work(shared: Weak, jobs: Receiver, proxy_port: u16) { + let mut finder = Finder::new(proxy_port); + while let Ok(first) = jobs.recv() { + let mut batch = vec![first]; + while let Ok(job) = jobs.try_recv() { + batch.push(job); + } + let Some(shared) = shared.upgrade() else { + return; + }; + // Some may be forgotten already, under a burst of newer connections. + batch.retain(|job| shared.lock().get(&job.connection).is_some()); + if batch.is_empty() { + continue; + } + let ports: Vec = batch.iter().map(|job| job.client_port).collect(); + let apps = finder.find_all(&ports); + let mut connections = shared.lock(); + for job in batch { + if let Some(slot) = connections.get_mut(&job.connection) { + *slot = Slot::Done(apps.get(&job.client_port).cloned().flatten()); + } + } + drop(connections); + shared.done.notify_all(); + } + } + + /// Finds the processes holding client sockets and the apps they belong to, with the caches + /// and buffers kept between lookups. + pub(super) struct Finder { + proxy_port: u16, + own_pid: i32, + /// Most recent first. + recent: Vec, + apps: Bounded<(i32, u64, u64), Option>, + pids: Vec, + fds: Vec, + held: Vec, + } + + impl Finder { + pub fn new(proxy_port: u16) -> Self { + Self { + proxy_port, + own_pid: std::process::id() as i32, + recent: Vec::new(), + apps: Bounded::new(APPS), + pids: Vec::new(), + fds: Vec::new(), + held: Vec::new(), + } + } + + /// The app behind each of the connections from `client_ports`, None for those not found. + pub fn find_all(&mut self, client_ports: &[u16]) -> HashMap> { + self.find_pids(client_ports) + .into_iter() + .map(|(port, pid)| (port, self.app(pid))) + .collect() + } + + /// The pids holding the client ends of connections from `client_ports`: first among the + /// pids that held recent ones, then in one scan of every other pid, stopping once all + /// are found. + pub fn find_pids(&mut self, client_ports: &[u16]) -> HashMap { + let mut pending: HashSet = client_ports.iter().copied().collect(); + let mut found = HashMap::with_capacity(pending.len()); + let mut hits = Vec::new(); + + let mut i = 0; + while i < self.recent.len() && !pending.is_empty() { + let pid = self.recent[i]; + let before = pending.len(); + if !self.check(pid, &mut pending, &mut found) { + self.recent.remove(i); + continue; + } + if pending.len() < before { + hits.push(pid); + } + i += 1; + } + + if !pending.is_empty() { + let recent: HashSet = self.recent.iter().copied().collect(); + let mut pids = std::mem::take(&mut self.pids); + proc::all_pids(&mut pids); + for &pid in &pids { + if pending.is_empty() { + break; + } + if pid <= 0 || pid == self.own_pid || recent.contains(&pid) { + continue; + } + let before = pending.len(); + self.check(pid, &mut pending, &mut found); + if pending.len() < before { + hits.push(pid); + } + } + self.pids = pids; + } + + self.recent.retain(|pid| !hits.contains(pid)); + self.recent.splice(0..0, hits); + self.recent.truncate(RECENT_PIDS); + found + } + + /// Moves the ports in `pending` that `pid` holds into `found`. False when `pid` cannot + /// be read. + fn check( + &mut self, + pid: i32, + pending: &mut HashSet, + found: &mut HashMap, + ) -> bool { + let readable = + proc::held_ports(pid, self.proxy_port, pending, &mut self.fds, &mut self.held); + for port in self.held.drain(..) { + if pending.remove(&port) { + found.insert(port, pid); + } + } + readable + } + + fn app(&mut self, pid: i32) -> Option { + let key = proc::start_time(pid).map(|(sec, usec)| (pid, sec, usec)); + if let Some(key) = key + && let Some(app) = self.apps.get(&key) + { + return app.clone(); + } + let app = proc::executable(pid).map(|exe| describe(pid, &exe)); + if let Some(key) = key { + self.apps.insert(key, app.clone()); + } + app + } + } + + /// The app that `exe`, the executable of `pid`, belongs to. + pub(super) fn describe(pid: i32, exe: &Path) -> ClientApp { + match outermost_bundle(exe) { + Some(bundle) => { + let (name, bundle_id) = bundle_info(bundle); + ClientApp { pid, name, bundle_id, path: bundle.to_string_lossy().into_owned() } + } + None => ClientApp { + pid, + name: exe + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| exe.to_string_lossy().into_owned()), + bundle_id: None, + path: exe.to_string_lossy().into_owned(), + }, + } + } + + /// The outermost ancestor directory of `exe` whose name ends in `.app`, so a helper app + /// nested inside another app resolves to the app the user launched. + pub(super) fn outermost_bundle(exe: &Path) -> Option<&Path> { + exe.ancestors() + .skip(1) + .filter(|dir| dir.file_name().is_some_and(|n| n.to_string_lossy().ends_with(".app"))) + .last() + } + + /// The name and bundle identifier of the app bundle at `bundle`, from its `Info.plist`: + /// `CFBundleDisplayName`, then `CFBundleName`, then the bundle's file stem. + pub(super) fn bundle_info(bundle: &Path) -> (String, Option) { + let info = plist::Value::from_file(bundle.join("Contents").join("Info.plist")) + .ok() + .and_then(plist::Value::into_dictionary); + let string = |key: &str| { + info.as_ref() + .and_then(|d| d.get(key)) + .and_then(plist::Value::as_string) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + }; + let name = + string("CFBundleDisplayName").or_else(|| string("CFBundleName")).unwrap_or_else(|| { + bundle.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default() + }); + (name, string("CFBundleIdentifier")) + } +} + +/// The `libproc` calls and the `` structs `libc` does not have, named as the +/// header names them, with every field so the layouts match. +#[cfg(target_os = "macos")] +#[allow(non_camel_case_types, dead_code)] +mod proc { + use libc::{c_char, c_int, c_void}; + use std::collections::HashSet; + use std::ffi::OsStr; + use std::mem::size_of; + use std::net::{Ipv4Addr, Ipv6Addr}; + use std::os::unix::ffi::OsStrExt; + use std::path::PathBuf; + + pub const PROC_PIDFDSOCKETINFO: c_int = 3; + pub const SOCKINFO_TCP: c_int = 2; + pub const INI_IPV4: u8 = 0x1; + + #[repr(C)] + #[derive(Clone, Copy)] + pub struct proc_fileinfo { + pub fi_openflags: u32, + pub fi_status: u32, + pub fi_offset: i64, + pub fi_type: i32, + pub fi_guardflags: u32, + } + + #[repr(C)] + #[derive(Clone, Copy)] + pub struct sockbuf_info { + pub sbi_cc: u32, + pub sbi_hiwat: u32, + pub sbi_mbcnt: u32, + pub sbi_mbmax: u32, + pub sbi_lowat: u32, + pub sbi_flags: i16, + pub sbi_timeo: i16, + } + + /// The `in4in6_addr` / `in6_addr` union: an IPv4 address is the last four bytes. + #[repr(C, align(4))] + #[derive(Clone, Copy)] + pub struct in_addr_46 { + pub bytes: [u8; 16], + } + + #[repr(C)] + #[derive(Clone, Copy)] + pub struct insi_v6 { + pub in6_hlim: u8, + pub in6_cksum: c_int, + pub in6_ifindex: u16, + pub in6_hops: i16, + } + + #[repr(C)] + #[derive(Clone, Copy)] + pub struct in_sockinfo { + /// The foreign port, network byte order in the low 16 bits. + pub insi_fport: c_int, + /// The local port, network byte order in the low 16 bits. + pub insi_lport: c_int, + pub insi_gencnt: u64, + pub insi_flags: u32, + pub insi_flow: u32, + pub insi_vflag: u8, + pub insi_ip_ttl: u8, + pub rfu_1: u32, + pub insi_faddr: in_addr_46, + pub insi_laddr: in_addr_46, + pub insi_v4: u8, + pub insi_v6: insi_v6, + } + + #[repr(C)] + #[derive(Clone, Copy)] + pub struct tcp_sockinfo { + pub tcpsi_ini: in_sockinfo, + pub tcpsi_state: c_int, + pub tcpsi_timer: [c_int; 4], + pub tcpsi_mss: c_int, + pub tcpsi_flags: u32, + pub rfu_1: u32, + pub tcpsi_tp: u64, + } + + /// Only here so `soi_proto` has the size of its largest member. + #[repr(C)] + #[derive(Clone, Copy)] + pub struct un_sockinfo { + pub unsi_conn_so: u64, + pub unsi_conn_pcb: u64, + pub unsi_addr: [c_char; 255], + pub unsi_caddr: [c_char; 255], + } + + #[repr(C)] + #[derive(Clone, Copy)] + pub union soi_proto { + pub pri_in: in_sockinfo, + pub pri_tcp: tcp_sockinfo, + pub pri_un: un_sockinfo, + } + + #[repr(C)] + #[derive(Clone, Copy)] + pub struct socket_info { + pub soi_stat: libc::vinfo_stat, + pub soi_so: u64, + pub soi_pcb: u64, + pub soi_type: c_int, + pub soi_protocol: c_int, + pub soi_family: c_int, + pub soi_options: i16, + pub soi_linger: i16, + pub soi_state: i16, + pub soi_qlen: i16, + pub soi_incqlen: i16, + pub soi_qlimit: i16, + pub soi_timeo: i16, + pub soi_error: u16, + pub soi_oobmark: u32, + pub soi_rcv: sockbuf_info, + pub soi_snd: sockbuf_info, + pub soi_kind: c_int, + pub rfu_1: u32, + pub soi_proto: soi_proto, + } + + #[repr(C)] + #[derive(Clone, Copy)] + pub struct socket_fdinfo { + pub pfi: proc_fileinfo, + pub psi: socket_info, + } + + /// Fills `pids` with every pid on the system. + pub fn all_pids(pids: &mut Vec) { + pids.clear(); + // With no buffer this returns about how many pids there are. + let count = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) }; + if count <= 0 { + return; + } + pids.resize(count as usize + 64, 0); + let bytes = (pids.len() * size_of::()) as c_int; + let count = unsafe { libc::proc_listallpids(pids.as_mut_ptr().cast::(), bytes) }; + pids.truncate(count.max(0) as usize); + } + + /// Fills `fds` with the open fds of `pid`. False when the process is gone or not ours to read. + fn list_fds(pid: i32, fds: &mut Vec) -> bool { + fds.clear(); + let entry = size_of::(); + // With no buffer this returns the bytes needed for the fd table. + let bytes = + unsafe { libc::proc_pidinfo(pid, libc::PROC_PIDLISTFDS, 0, std::ptr::null_mut(), 0) }; + if bytes <= 0 { + return false; + } + fds.resize(bytes as usize / entry + 16, libc::proc_fdinfo { proc_fd: 0, proc_fdtype: 0 }); + let bytes = unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDLISTFDS, + 0, + fds.as_mut_ptr().cast::(), + (fds.len() * entry) as c_int, + ) + }; + if bytes <= 0 { + fds.clear(); + return false; + } + fds.truncate(bytes as usize / entry); + true + } + + /// Fills `held` with the ports of `wanted` that `pid` holds the client end of: a loopback + /// TCP socket from that port to `proxy_port`. False when its fds cannot be read. + pub fn held_ports( + pid: i32, + proxy_port: u16, + wanted: &HashSet, + fds: &mut Vec, + held: &mut Vec, + ) -> bool { + held.clear(); + if !list_fds(pid, fds) { + return false; + } + let size = size_of::(); + for fd in fds.iter().filter(|fd| fd.proc_fdtype == libc::PROX_FDTYPE_SOCKET as u32) { + let mut info = std::mem::MaybeUninit::::zeroed(); + let written = unsafe { + libc::proc_pidfdinfo( + pid, + fd.proc_fd, + PROC_PIDFDSOCKETINFO, + info.as_mut_ptr().cast::(), + size as c_int, + ) + }; + if written as usize != size { + continue; + } + // Zeroed and then filled by the kernel, so every field holds a valid value. + let info = unsafe { info.assume_init() }; + if info.psi.soi_kind != SOCKINFO_TCP { + continue; + } + // soi_kind says the union holds a tcp_sockinfo. + let ini = unsafe { info.psi.soi_proto.pri_tcp.tcpsi_ini }; + let local = port(ini.insi_lport); + if port(ini.insi_fport) == proxy_port + && wanted.contains(&local) + && is_loopback(ini.insi_vflag, &ini.insi_laddr) + && is_loopback(ini.insi_vflag, &ini.insi_faddr) + { + held.push(local); + if held.len() == wanted.len() { + break; + } + } + } + true + } + + pub fn port(raw: c_int) -> u16 { + u16::from_be(raw as u16) + } + + pub fn is_loopback(vflag: u8, addr: &in_addr_46) -> bool { + let b = addr.bytes; + if vflag & INI_IPV4 != 0 { + return Ipv4Addr::new(b[12], b[13], b[14], b[15]).is_loopback(); + } + let v6 = Ipv6Addr::from(b); + v6.is_loopback() || v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback()) + } + + /// When `pid` started, in seconds and microseconds, which tells a reused pid apart. + pub fn start_time(pid: i32) -> Option<(u64, u64)> { + let mut info = std::mem::MaybeUninit::::zeroed(); + let size = size_of::(); + let written = unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr().cast::(), + size as c_int, + ) + }; + if written as usize != size { + return None; + } + let info = unsafe { info.assume_init() }; + Some((info.pbi_start_tvsec, info.pbi_start_tvusec)) + } + + /// The path of the executable `pid` runs. + pub fn executable(pid: i32) -> Option { + let mut buf = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; + let len = + unsafe { libc::proc_pidpath(pid, buf.as_mut_ptr().cast::(), buf.len() as u32) }; + if len <= 0 { + return None; + } + buf.truncate(len as usize); + Some(PathBuf::from(OsStr::from_bytes(&buf))) + } +} + +/// App icons drawn with AppKit. +#[cfg(target_os = "macos")] +mod icon { + use super::Bounded; + use objc2::AllocAnyThread; + use objc2::rc::autoreleasepool; + use objc2_app_kit::{ + NSBitmapImageFileType, NSBitmapImageRep, NSCompositingOperation, NSDeviceRGBColorSpace, + NSGraphicsContext, NSWorkspace, + }; + use objc2_foundation::{NSDictionary, NSPoint, NSRect, NSSize, NSString}; + use std::path::Path; + use std::sync::{LazyLock, Mutex, PoisonError}; + + const SIZE: isize = 64; + const CACHED: usize = 256; + + static ICONS: LazyLock>>> = + LazyLock::new(|| Mutex::new(Bounded::new(CACHED))); + + pub fn png(path: &str) -> Result, String> { + if let Some(png) = ICONS.lock().unwrap_or_else(PoisonError::into_inner).get(path) { + return Ok(png.clone()); + } + if !Path::new(path).exists() { + return Err(format!("No app at {path}")); + } + let png = autoreleasepool(|_| draw(path))?; + ICONS.lock().unwrap_or_else(PoisonError::into_inner).insert(path.to_string(), png.clone()); + Ok(png) + } + + fn draw(path: &str) -> Result, String> { + let image = NSWorkspace::sharedWorkspace().iconForFile(&NSString::from_str(path)); + // A null planes pointer has the rep allocate its own pixels. + let rep = unsafe { + NSBitmapImageRep::initWithBitmapDataPlanes_pixelsWide_pixelsHigh_bitsPerSample_samplesPerPixel_hasAlpha_isPlanar_colorSpaceName_bytesPerRow_bitsPerPixel( + NSBitmapImageRep::alloc(), + std::ptr::null_mut(), + SIZE, + SIZE, + 8, + 4, + true, + false, + NSDeviceRGBColorSpace, + 0, + 0, + ) + } + .ok_or("Failed to create a bitmap for the icon")?; + let size = NSSize::new(SIZE as f64, SIZE as f64); + rep.setSize(size); + let context = NSGraphicsContext::graphicsContextWithBitmapImageRep(&rep) + .ok_or("Failed to create a graphics context for the icon")?; + NSGraphicsContext::saveGraphicsState_class(); + NSGraphicsContext::setCurrentContext(Some(&context)); + image.drawInRect_fromRect_operation_fraction( + NSRect::new(NSPoint::ZERO, size), + NSRect::ZERO, + NSCompositingOperation::Copy, + 1.0, + ); + context.flushGraphics(); + NSGraphicsContext::restoreGraphicsState_class(); + // An empty properties dictionary is the right type for any key. + let data = unsafe { + rep.representationUsingType_properties(NSBitmapImageFileType::PNG, &NSDictionary::new()) + } + .ok_or("Failed to encode the icon as PNG")?; + Ok(data.to_vec()) + } +} + +#[cfg(all(test, target_os = "macos"))] +mod tests { + use super::imp::{Finder, bundle_info, describe, outermost_bundle}; + use super::proc::*; + use super::*; + use std::mem::{offset_of, size_of}; + use std::net::{Ipv4Addr, Ipv6Addr, TcpListener}; + use std::path::Path; + use std::process::{Child, Command, Stdio}; + use std::time::Instant; + + #[test] + fn struct_layouts_match_the_sdk() { + // sizeof and offsetof from in the macOS SDK. + assert_eq!(size_of::(), 24); + assert_eq!(size_of::(), 136); + assert_eq!(size_of::(), 24); + assert_eq!(size_of::(), 80); + assert_eq!(size_of::(), 120); + assert_eq!(size_of::(), 528); + assert_eq!(size_of::(), 768); + assert_eq!(size_of::(), 792); + assert_eq!(offset_of!(in_sockinfo, insi_lport), 4); + assert_eq!(offset_of!(in_sockinfo, insi_vflag), 24); + assert_eq!(offset_of!(in_sockinfo, insi_faddr), 32); + assert_eq!(offset_of!(in_sockinfo, insi_laddr), 48); + assert_eq!(offset_of!(in_sockinfo, insi_v6), 68); + assert_eq!(offset_of!(socket_info, soi_kind), 232); + assert_eq!(offset_of!(socket_info, soi_proto), 240); + assert_eq!(offset_of!(socket_fdinfo, psi), 24); + } + + #[test] + fn reads_ports_and_loopback() { + assert_eq!(port(i32::from(9090u16.to_be())), 9090); + let mut v4 = in_addr_46 { bytes: [0; 16] }; + v4.bytes[12..].copy_from_slice(&[127, 0, 0, 1]); + assert!(is_loopback(INI_IPV4, &v4)); + let v6 = in_addr_46 { bytes: Ipv6Addr::LOCALHOST.octets() }; + assert!(is_loopback(2, &v6)); + let mapped = in_addr_46 { bytes: Ipv4Addr::new(127, 0, 0, 1).to_ipv6_mapped().octets() }; + assert!(is_loopback(2, &mapped)); + let remote = in_addr_46 { bytes: Ipv4Addr::new(10, 0, 0, 1).to_ipv6_mapped().octets() }; + assert!(!is_loopback(2, &remote)); + } + + fn fake_app(dir: &Path, rel: &str, plist: Option<&str>) { + let bundle = dir.join(rel); + std::fs::create_dir_all(bundle.join("Contents/MacOS")).unwrap(); + if let Some(plist) = plist { + std::fs::write(bundle.join("Contents/Info.plist"), plist).unwrap(); + } + } + + fn plist_xml(entries: &[(&str, &str)]) -> String { + let body: String = + entries.iter().map(|(k, v)| format!("{k}{v}")).collect(); + format!( + r#"{body}"# + ) + } + + #[test] + fn resolves_the_outermost_bundle() { + let dir = tempfile::tempdir().unwrap(); + let outer = "Fake Browser.app"; + let helper = "Fake Browser.app/Contents/Frameworks/Fake.framework/Helpers/Fake Helper (Renderer).app"; + fake_app( + dir.path(), + outer, + Some(&plist_xml(&[ + ("CFBundleIdentifier", "com.example.browser"), + ("CFBundleName", "Browser"), + ("CFBundleDisplayName", "Fake Browser"), + ])), + ); + fake_app( + dir.path(), + helper, + Some(&plist_xml(&[("CFBundleIdentifier", "com.example.browser.helper")])), + ); + let exe = dir.path().join(helper).join("Contents/MacOS/Fake Helper (Renderer)"); + let bundle = dir.path().join(outer); + assert_eq!(outermost_bundle(&exe), Some(bundle.as_path())); + assert_eq!( + describe(42, &exe), + ClientApp { + pid: 42, + name: "Fake Browser".into(), + bundle_id: Some("com.example.browser".into()), + path: bundle.to_string_lossy().into_owned(), + } + ); + } + + #[test] + fn falls_back_to_the_executable() { + let exe = Path::new("/usr/bin/curl"); + assert_eq!(outermost_bundle(exe), None); + assert_eq!( + describe(7, exe), + ClientApp { + pid: 7, + name: "curl".into(), + bundle_id: None, + path: "/usr/bin/curl".into() + } + ); + } + + #[test] + fn reads_bundle_names() { + let dir = tempfile::tempdir().unwrap(); + fake_app(dir.path(), "Named.app", Some(&plist_xml(&[("CFBundleName", "Only Name")]))); + assert_eq!(bundle_info(&dir.path().join("Named.app")), ("Only Name".into(), None)); + + fake_app(dir.path(), "Bare.app", None); + assert_eq!(bundle_info(&dir.path().join("Bare.app")), ("Bare".into(), None)); + + // A binary plist, as most shipped apps have. + let mut dict = plist::Dictionary::new(); + dict.insert("CFBundleIdentifier".into(), "com.example.binary".into()); + dict.insert("CFBundleDisplayName".into(), "Binary App".into()); + fake_app(dir.path(), "Binary.app", None); + plist::Value::Dictionary(dict) + .to_file_binary(dir.path().join("Binary.app/Contents/Info.plist")) + .unwrap(); + assert_eq!( + bundle_info(&dir.path().join("Binary.app")), + ("Binary App".into(), Some("com.example.binary".into())) + ); + } + + /// `n` curls connected to one listener, with their accepted ends and client addresses. + struct Curls { + port: u16, + children: Vec, + streams: Vec, + peers: Vec, + } + + impl Curls { + fn start(n: usize) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let children = (0..n) + .map(|_| { + Command::new("/usr/bin/curl") + .args([ + "-s", + "--max-time", + "5", + &format!("http://127.0.0.1:{port}/"), + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap() + }) + .collect(); + let (streams, peers) = (0..n).map(|_| listener.accept().unwrap()).unzip(); + Self { port, children, streams, peers } + } + + fn pids(&self) -> Vec { + let mut pids: Vec = self.children.iter().map(|c| c.id() as i32).collect(); + pids.sort(); + pids + } + + fn client_ports(&self) -> Vec { + self.peers.iter().map(|p| p.port()).collect() + } + } + + impl Drop for Curls { + fn drop(&mut self) { + self.streams.clear(); + for child in &mut self.children { + let _ = child.kill(); + let _ = child.wait(); + } + } + } + + #[test] + fn finds_curl() { + let curls = Curls::start(1); + let attribution = Attribution::new(curls.port); + attribution.begin(1, curls.peers[0]); + attribution.begin(1, curls.peers[0]); + let app = attribution.get(1, Duration::from_secs(2)).expect("found the app"); + assert_eq!(app.name, "curl"); + assert_eq!(app.bundle_id, None); + assert_eq!(vec![app.pid], curls.pids()); + assert_eq!(attribution.get(2, Duration::from_millis(10)), None); + } + + #[test] + fn finds_many_clients_in_one_pass() { + let curls = Curls::start(6); + let mut finder = Finder::new(curls.port); + // Port 1 is never a client, so the pass also scans every pid. + let mut ports = curls.client_ports(); + ports.push(1); + let apps = finder.find_all(&ports); + assert_eq!(apps.len(), 6); + let mut pids: Vec = apps.values().flatten().map(|a| a.pid).collect(); + pids.sort(); + assert_eq!(pids, curls.pids()); + assert!(apps.values().flatten().all(|a| a.name == "curl")); + // Each port maps to the curl that holds it, the same through the recent pids. + assert_eq!(finder.find_pids(&curls.client_ports()), finder.find_pids(&ports)); + + let attribution = Attribution::new(curls.port); + for (i, peer) in curls.peers.iter().enumerate() { + attribution.begin(i as u64, *peer); + } + for (i, port) in curls.client_ports().iter().enumerate() { + let app = attribution.get(i as u64, Duration::from_secs(2)); + assert_eq!(app, apps[port]); + } + } + + #[test] + fn draws_app_icons() { + let png = app_icon_png("/System/Applications/Calculator.app").unwrap().unwrap(); + assert!(png.starts_with(b"\x89PNG\r\n\x1a\n")); + assert_eq!(&png[16..24], &[0, 0, 0, 64, 0, 0, 0, 64]); + let cached = app_icon_png("/System/Applications/Calculator.app").unwrap().unwrap(); + assert_eq!(png, cached); + assert!(app_icon_png("/usr/bin/curl").unwrap().unwrap().starts_with(b"\x89PNG")); + } + + /// Run with `--ignored --nocapture` to see the timings. + #[test] + #[ignore] + fn times_lookups() { + let mut pids = Vec::new(); + all_pids(&mut pids); + eprintln!("{} pids", pids.len()); + for n in [1, 8, 32] { + let curls = Curls::start(n); + let ports = curls.client_ports(); + let mut finder = Finder::new(curls.port); + let start = Instant::now(); + let found = finder.find_pids(&ports); + let full = start.elapsed(); + let start = Instant::now(); + let cached = finder.find_pids(&ports); + let hit = start.elapsed(); + let mut with_miss = ports.clone(); + with_miss.push(1); + let start = Instant::now(); + finder.find_pids(&with_miss); + let miss = start.elapsed(); + assert_eq!(found.len(), n); + assert_eq!(cached, found); + eprintln!( + "{n} ports: full scan {full:?}, cached pids {hit:?}, cached plus one never found (whole scan) {miss:?}" + ); + } + } +} diff --git a/crates-proxy/yaak-proxy-lib/src/bodies.rs b/crates-proxy/yaak-proxy-lib/src/bodies.rs new file mode 100644 index 000000000..cd249a6fd --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/src/bodies.rs @@ -0,0 +1,275 @@ +use crate::models::{Flow, FlowBody, find_header}; +use std::io::Cursor; +use tokio::io::AsyncReadExt; +use yaak_http::decompress::{ContentEncoding, streaming_decoder}; +use yaak_models::blob_manager::BlobManager; + +/// A flow's body as stored: the bytes on the wire, up to the capture limit. +pub fn read_raw_body( + blobs: &BlobManager, + flow_id: &str, + body: FlowBody, +) -> Result, String> { + let chunks = + blobs.connect().get_chunks(&Flow::body_id(flow_id, body)).map_err(|e| e.to_string())?; + Ok(chunks.into_iter().flat_map(|c| c.data).collect()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecodedBody { + pub data: Vec, + /// Bytes stored for the body, as they were on the wire. + pub stored_size: usize, + /// The body is incomplete: cut on the wire at the capture limit, decoded past `limit`, or + /// decoded only as far as `decode_error`. + pub truncated: bool, + /// Why decoding stopped before the end of the stored bytes. + pub decode_error: Option, +} + +/// A flow's body decoded with its Content-Encoding, at most `limit` bytes of it. A body that +/// fails to decode, as one cut short does, keeps what decoded before the failure. +pub async fn read_body( + blobs: &BlobManager, + flow: &Flow, + body: FlowBody, + limit: usize, +) -> Result { + let raw = read_raw_body(blobs, &flow.id, body)?; + let stored_size = raw.len(); + // A response that failed or was cancelled after its headers ended short on the wire. + let cut = body == FlowBody::Response + && flow.status.is_some() + && (flow.error.is_some() || flow.cancelled); + let wire_truncated = flow.truncated(body) || cut; + let encoding = + ContentEncoding::from_header(find_header(flow.headers(body), "content-encoding")); + // A HEAD or 304 response names its encoding but has no body to decode. + if raw.is_empty() || encoding == ContentEncoding::Identity { + let truncated = wire_truncated || raw.len() > limit; + let mut data = raw; + data.truncate(limit); + return Ok(DecodedBody { data, stored_size, truncated, decode_error: None }); + } + + // One byte past the limit tells a body that fills it from one that overflows it. + let take = u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1); + let mut data = Vec::new(); + let mut decoder = streaming_decoder(Cursor::new(raw), encoding).take(take); + let decode_error = match decoder.read_to_end(&mut data).await { + Ok(_) => None, + // Expected of a body cut on the wire; anything else is worth saying. + Err(_) if wire_truncated => None, + Err(e) => Some(format!("Failed to decode {encoding:?} body: {e}")), + }; + let truncated = wire_truncated || decode_error.is_some() || data.len() > limit; + data.truncate(limit); + Ok(DecodedBody { data, stored_size, truncated, decode_error }) +} + +/// A body's text as the app's viewer reads it, as `decodeBodyText` in `@yaakapp-internal/lib` +/// does: in the charset its content type names, when it names one the WHATWG Encoding Standard +/// knows, or else as UTF-8, or windows-1252 when it isn't valid UTF-8, as browsers read text. +pub fn body_text(data: &[u8], content_type: Option<&str>, truncated: bool) -> String { + let encoding = content_type + .and_then(charset_label) + .and_then(|label| encoding_rs::Encoding::for_label(label.as_bytes())) + // A label the standard maps to "replacement" is one `TextDecoder` refuses. + .filter(|encoding| *encoding != encoding_rs::REPLACEMENT); + if let Some(encoding) = encoding { + return encoding.decode_with_bom_removal(data).0.into_owned(); + } + if let Some(text) = strict_utf8(data, truncated) { + return text; + } + encoding_rs::WINDOWS_1252.decode_without_bom_handling(data).0.into_owned() +} + +/// The `charset` parameter of a content type +fn charset_label(content_type: &str) -> Option<&str> { + content_type.split(';').skip(1).find_map(|param| { + let (name, value) = param.split_once('=')?; + name.trim().eq_ignore_ascii_case("charset").then(|| value.trim().trim_matches('"').trim()) + }) +} + +/// The bytes as UTF-8 without a byte order mark, or None when they aren't valid UTF-8. A +/// truncated body may end partway through a character, so up to three trailing bytes are dropped +/// before giving up on it. +fn strict_utf8(data: &[u8], truncated: bool) -> Option { + let max_trim = if truncated { data.len().min(3) } else { 0 }; + (0..=max_trim).find_map(|trim| { + let text = std::str::from_utf8(&data[..data.len() - trim]).ok()?; + Some(text.strip_prefix('\u{feff}').unwrap_or(text).to_string()) + }) +} + +/// A flow's body decoded as `read_body` does, or, when it fails to decode, the bytes as they were +/// on the wire, with `decode_error` saying why. +pub async fn read_body_or_raw( + blobs: &BlobManager, + flow: &Flow, + body: FlowBody, + limit: usize, +) -> Result { + let decoded = read_body(blobs, flow, body, limit).await?; + if decoded.decode_error.is_none() { + return Ok(decoded); + } + let mut data = read_raw_body(blobs, &flow.id, body)?; + let truncated = flow.truncated(body) || data.len() > limit; + data.truncate(limit); + Ok(DecodedBody { data, truncated, ..decoded }) +} + +#[cfg(test)] +mod tests { + use super::*; + use yaak_models::blob_manager::BodyChunkWriter; + + #[tokio::test] + async fn response_failed_after_its_headers_is_truncated() { + let (_db, blobs, _rx) = yaak_models::init_in_memory().unwrap(); + let mut writer = + BodyChunkWriter::new(blobs.clone(), Flow::body_id("fl_cut", FlowBody::Response)); + writer.write(b"{\"rows\": [").unwrap(); + writer.finish().unwrap(); + let cut = Flow { + id: "fl_cut".into(), + status: Some(200), + error: Some("error reading a body from connection".into()), + ..Default::default() + }; + + let body = read_body(&blobs, &cut, FlowBody::Response, 1024).await.unwrap(); + assert_eq!((body.data.as_slice(), body.truncated), (&b"{\"rows\": ["[..], true)); + // The request went out whole; the error is the response's. + let request = read_body(&blobs, &cut, FlowBody::Request, 1024).await.unwrap(); + assert!(!request.truncated); + // With no headers there was never a response body to cut. + let refused = Flow { status: None, ..cut.clone() }; + assert!(!read_body(&blobs, &refused, FlowBody::Response, 1024).await.unwrap().truncated); + + // The client cancelling partway through cuts it short the same way. + let cancelled = Flow { error: None, cancelled: true, ..cut }; + let body = read_body(&blobs, &cancelled, FlowBody::Response, 1024).await.unwrap(); + assert_eq!((body.data.as_slice(), body.truncated), (&b"{\"rows\": ["[..], true)); + } + + #[tokio::test] + async fn cancelled_gzip_response_keeps_what_decoded() { + use std::io::Write; + let (_db, blobs, _rx) = yaak_models::init_in_memory().unwrap(); + let mut gzip = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + gzip.write_all(&[b'z'; 4096]).unwrap(); + let gzip = gzip.finish().unwrap(); + let mut writer = + BodyChunkWriter::new(blobs.clone(), Flow::body_id("fl_gz", FlowBody::Response)); + writer.write(&gzip[..gzip.len() / 2]).unwrap(); + writer.finish().unwrap(); + let flow = Flow { + id: "fl_gz".into(), + status: Some(200), + cancelled: true, + response_headers: vec![crate::models::ProxyHeader { + name: "content-encoding".into(), + value: "gzip".into(), + }], + ..Default::default() + }; + + let body = read_body(&blobs, &flow, FlowBody::Response, 8192).await.unwrap(); + assert!(body.truncated); + assert_eq!(body.decode_error, None); + } + + /// The cases `decodeBodyText`'s tests in `@yaakapp-internal/lib` cover, read the same way + #[test] + fn body_text_reads_as_the_viewer_does() { + let french = "Accès refusé : vous n'avez pas l'autorisation d'accéder à cette page."; + let latin1: Vec = french.chars().map(|c| c as u8).collect(); + assert_eq!(body_text(&latin1, Some("text/html; charset=iso-8859-1"), false), french); + assert_eq!(body_text(&latin1, Some("text/plain; CHARSET=\"ISO-8859-1\""), false), french); + let shift_jis = [ + 0x82, 0xb1, 0x82, 0xf1, 0x82, 0xc9, 0x82, 0xbf, 0x82, 0xcd, 0x81, 0x41, 0x90, 0xa2, + 0x8a, 0x45, + ]; + assert_eq!( + body_text(&shift_jis, Some("text/plain; charset=Shift_JIS"), false), + "こんにちは、世界" + ); + + // Unlabeled and not UTF-8: windows-1252 + assert_eq!(body_text(&latin1, Some("text/html"), false), french); + assert_eq!(body_text(&latin1, None, false), french); + assert_eq!(body_text(&[0x93, b'h', b'i', 0x94], None, false), "\u{201c}hi\u{201d}"); + // A label `TextDecoder` refuses is no label. + assert_eq!(body_text(&latin1, Some("text/plain; charset=not-a-charset"), false), french); + assert_eq!(body_text(&latin1, Some("text/plain; charset=iso-2022-kr"), false), french); + + // UTF-8, labeled or not; labeled, what isn't UTF-8 is replaced + let json = r#"{"city":"Zürich","greeting":"こんにちは"}"#; + assert_eq!(body_text(json.as_bytes(), None, false), json); + assert_eq!( + body_text(json.as_bytes(), Some("application/json; charset=utf-8"), false), + json + ); + assert_eq!(body_text(b"a\xff", Some("text/plain; charset=utf-8"), false), "a\u{fffd}"); + + // A UTF-8 body cut partway through a character stays UTF-8 + assert_eq!(body_text(&"abc€".as_bytes()[..5], None, true), "abc"); + } + + fn deflate_request(blobs: &BlobManager, id: &str, stored: &[u8]) -> Flow { + let mut writer = BodyChunkWriter::new(blobs.clone(), Flow::body_id(id, FlowBody::Request)); + writer.write(stored).unwrap(); + writer.finish().unwrap(); + Flow { + id: id.into(), + request_size: stored.len() as i64, + request_headers: vec![crate::models::ProxyHeader { + name: "Content-Encoding".into(), + value: "deflate".into(), + }], + ..Default::default() + } + } + + /// Browser SDKs send `deflate` both ways: zlib-wrapped, as the spec says, and raw. + #[tokio::test] + async fn deflate_request_bodies_decode_wrapped_or_raw() { + use std::io::Write; + let (_db, blobs, _rx) = yaak_models::init_in_memory().unwrap(); + let logs = br#"[{"message":"hello","service":"browser"}]"#; + let mut zlib = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + zlib.write_all(logs).unwrap(); + let mut raw = + flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + raw.write_all(logs).unwrap(); + + for (id, stored) in [ + ("fl_zlib", zlib.finish().unwrap()), + ("fl_raw", raw.finish().unwrap()), + ] { + let flow = deflate_request(&blobs, id, &stored); + let body = read_body(&blobs, &flow, FlowBody::Request, 1024).await.unwrap(); + assert_eq!(body.decode_error, None, "{id}"); + assert_eq!((body.data.as_slice(), body.truncated), (&logs[..], false), "{id}"); + } + } + + #[tokio::test] + async fn undecodable_body_reads_as_it_was_on_the_wire() { + let (_db, blobs, _rx) = yaak_models::init_in_memory().unwrap(); + let flow = deflate_request(&blobs, "fl_bad", b"\x00\x01 not deflate"); + + let decoded = read_body(&blobs, &flow, FlowBody::Request, 1024).await.unwrap(); + assert!(decoded.decode_error.is_some()); + let raw = read_body_or_raw(&blobs, &flow, FlowBody::Request, 1024).await.unwrap(); + assert_eq!(raw.data, b"\x00\x01 not deflate"); + assert!(!raw.truncated); + assert_eq!(raw.decode_error, decoded.decode_error); + let cut = read_body_or_raw(&blobs, &flow, FlowBody::Request, 4).await.unwrap(); + assert_eq!((cut.data.as_slice(), cut.truncated), (&b"\x00\x01 n"[..], true)); + } +} diff --git a/crates-proxy/yaak-proxy-lib/src/ca.rs b/crates-proxy/yaak-proxy-lib/src/ca.rs new file mode 100644 index 000000000..a36f164b1 --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/src/ca.rs @@ -0,0 +1,75 @@ +use serde::{Deserialize, Serialize}; +use std::path::Path; +use ts_rs::TS; + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct CaStatus { + /// macOS trusts this app's CA, so HTTPS flows can be decrypted. + pub trusted: bool, + /// A certificate named "Yaak Proxy CA" is in a keychain: this one, or an older one. + pub installed: bool, + pub cert_path: String, +} + +#[cfg(target_os = "macos")] +const CA_NAME: &str = "Yaak Proxy CA"; +#[cfg(target_os = "macos")] +const SECURITY: &str = "/usr/bin/security"; + +#[cfg(target_os = "macos")] +pub async fn status(cert_path: &Path) -> Result { + let installed = security(&["find-certificate", "-c", CA_NAME]).await?.status.success(); + let path = cert_path.to_string_lossy(); + // verify-cert fails both for a missing file and for an older CA trusted under the same name. + let trusted = installed + && cert_path.exists() + && security(&["verify-cert", "-c", &path, "-L", "-q"]).await?.status.success(); + Ok(CaStatus { trusted, installed, cert_path: path.into_owned() }) +} + +/// Adds the CA to the login keychain as a trusted root. macOS asks for the user's password; +/// the app never elevates. +#[cfg(target_os = "macos")] +pub async fn trust(cert_path: &Path) -> Result<(), String> { + let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?; + let keychain = Path::new(&home).join("Library/Keychains/login.keychain-db"); + let output = security(&[ + "add-trusted-cert", + "-r", + "trustRoot", + "-k", + &keychain.to_string_lossy(), + &cert_path.to_string_lossy(), + ]) + .await?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if stderr.is_empty() { format!("security exited with {}", output.status) } else { stderr }) +} + +#[cfg(target_os = "macos")] +async fn security(args: &[&str]) -> Result { + tokio::process::Command::new(SECURITY) + .args(args) + .output() + .await + .map_err(|e| format!("Failed to run {SECURITY}: {e}")) +} + +#[cfg(not(target_os = "macos"))] +pub async fn status(cert_path: &Path) -> Result { + Ok(CaStatus { + trusted: false, + installed: false, + cert_path: cert_path.to_string_lossy().into_owned(), + }) +} + +#[cfg(not(target_os = "macos"))] +pub async fn trust(_cert_path: &Path) -> Result<(), String> { + Err("Trusting the certificate from the app is only supported on macOS".to_string()) +} diff --git a/crates-proxy/yaak-proxy-lib/src/client.rs b/crates-proxy/yaak-proxy-lib/src/client.rs new file mode 100644 index 000000000..914fa4c3a --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/src/client.rs @@ -0,0 +1,235 @@ +//! The other side of the control API, for the CLI and the MCP tools: find the owner through +//! `control.json` and call it with its token, or, when nothing owns the engine, read `proxy.db` +//! and `blobs.sqlite` read-only so captured traffic stays reachable. + +use crate::bodies::{self, DecodedBody}; +use crate::ca::CaStatus; +use crate::control::{ + self, ControlInfo, ControlStatus, ErrorBody, RecordingRequest, StartRequest, + SystemProxyRequest, TRUNCATED_HEADER, +}; +use crate::db::ProxyQueryManager; +use crate::flows::{self, FlowDetail, FlowFilter}; +use crate::har::{self, Har}; +use crate::macos::SystemProxyStatus; +use crate::models::{Flow, FlowBody}; +use crate::save::{SaveFlowRequest, SaveFlowResponse, WorkspaceSummary}; +use crate::settings::ProxySettings; +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::path::Path; +use yaak_models::blob_manager::BlobManager; + +/// Where captured traffic is read from. +pub enum ProxyConnection { + /// A process owns the engine; everything goes through its control API. + Live(ControlClient), + /// Nothing owns the engine; flows are read from disk. + Offline(OfflineStore), +} + +impl ProxyConnection { + /// The live owner in `data_dir`, or its databases when there is none. + pub fn connect(data_dir: &Path) -> Result { + match control::live_owner(data_dir) { + Some(info) => Ok(Self::Live(ControlClient::new(info))), + None => Ok(Self::Offline(OfflineStore::open(data_dir)?)), + } + } + + pub async fn flows(&self, filter: &FlowFilter) -> Result, String> { + match self { + Self::Live(c) => c.flows(filter).await, + Self::Offline(s) => s.flows(filter), + } + } + + pub async fn flow(&self, id: &str, body_limit: usize) -> Result { + match self { + Self::Live(c) => c.flow(id, body_limit).await, + Self::Offline(s) => s.flow(id, body_limit).await, + } + } + + pub async fn body(&self, id: &str, which: FlowBody) -> Result { + match self { + Self::Live(c) => c.body(id, which).await, + Self::Offline(s) => s.body(id, which).await, + } + } + + pub async fn har(&self, filter: &FlowFilter) -> Result { + match self { + Self::Live(c) => c.har(filter).await, + Self::Offline(s) => s.har(filter).await, + } + } +} + +/// Calls the control API of the owner `control.json` names. +pub struct ControlClient { + info: ControlInfo, + http: reqwest::Client, +} + +impl ControlClient { + pub fn new(info: ControlInfo) -> Self { + // Never through the system proxy, which may be the engine itself. + let http = reqwest::Client::builder().no_proxy().build().unwrap_or_default(); + Self { info, http } + } + + pub fn info(&self) -> &ControlInfo { + &self.info + } + + pub async fn status(&self) -> Result { + self.get("/status").await + } + + pub async fn start(&self, port: Option) -> Result { + self.post("/start", &StartRequest { port }).await + } + + pub async fn stop(&self) -> Result { + self.post("/stop", &serde_json::json!({})).await + } + + pub async fn flows(&self, filter: &FlowFilter) -> Result, String> { + self.get(&format!("/flows?{}", filter.to_query())).await + } + + pub async fn flow(&self, id: &str, body_limit: usize) -> Result { + self.get(&format!("/flows/{}?bodyLimit={body_limit}", encode(id))).await + } + + pub async fn body(&self, id: &str, which: FlowBody) -> Result { + let side = match which { + FlowBody::Request => "request-body", + FlowBody::Response => "response-body", + }; + let resp = + self.send(self.http.get(self.url(&format!("/flows/{}/{side}", encode(id))))).await?; + let truncated = resp.headers().get(TRUNCATED_HEADER).is_some_and(|v| v == "true"); + let data = resp.bytes().await.map_err(|e| e.to_string())?.to_vec(); + Ok(DecodedBody { stored_size: data.len(), data, truncated, decode_error: None }) + } + + pub async fn har(&self, filter: &FlowFilter) -> Result { + self.get(&format!("/har?{}", filter.to_query())).await + } + + pub async fn ca_pem(&self) -> Result { + let resp = self.send(self.http.get(self.url("/ca"))).await?; + resp.text().await.map_err(|e| e.to_string()) + } + + pub async fn trust_ca(&self) -> Result { + self.post("/ca/trust", &serde_json::json!({})).await + } + + /// Resumes or pauses storing flows in the owner's engine. + pub async fn set_recording(&self, enabled: bool) -> Result { + self.post("/recording", &RecordingRequest { enabled }).await + } + + pub async fn system_proxy(&self) -> Result { + self.get("/system-proxy").await + } + + pub async fn set_system_proxy(&self, enabled: bool) -> Result { + self.post("/system-proxy", &SystemProxyRequest { enabled }).await + } + + pub async fn workspaces(&self) -> Result, String> { + self.get("/workspaces").await + } + + pub async fn save(&self, id: &str, req: &SaveFlowRequest) -> Result { + self.post(&format!("/flows/{}/save", encode(id)), req).await + } + + fn url(&self, path: &str) -> String { + format!("{}{path}", self.info.base_url()) + } + + async fn get(&self, path: &str) -> Result { + let resp = self.send(self.http.get(self.url(path))).await?; + resp.json().await.map_err(|e| format!("Invalid response from the proxy: {e}")) + } + + async fn post( + &self, + path: &str, + body: &B, + ) -> Result { + let body = serde_json::to_vec(body).map_err(|e| e.to_string())?; + let req = + self.http.post(self.url(path)).header("content-type", "application/json").body(body); + let resp = self.send(req).await?; + resp.json().await.map_err(|e| format!("Invalid response from the proxy: {e}")) + } + + async fn send(&self, req: reqwest::RequestBuilder) -> Result { + let resp = req + .bearer_auth(&self.info.token) + .send() + .await + .map_err(|e| format!("Failed to reach the proxy's control API: {e}"))?; + if resp.status().is_success() { + return Ok(resp); + } + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + Err(serde_json::from_str::(&text) + .map(|e| e.error) + .unwrap_or_else(|_| format!("The proxy answered {status}: {text}"))) + } +} + +fn encode(segment: &str) -> String { + url::form_urlencoded::byte_serialize(segment.as_bytes()).collect() +} + +/// `proxy.db` and `blobs.sqlite`, opened read-only while no process owns the engine. +pub struct OfflineStore { + db: ProxyQueryManager, + blobs: BlobManager, + capture_body_limit: usize, +} + +impl OfflineStore { + pub fn open(data_dir: &Path) -> Result { + let db_path = data_dir.join("proxy.db"); + if !db_path.exists() { + return Err(format!( + "No captured traffic yet: {} does not exist. Start Yaak Proxy to capture some.", + db_path.display() + )); + } + let db = ProxyQueryManager::open_read_only(&db_path) + .map_err(|e| format!("Failed to open {}: {e}", db_path.display()))?; + let blobs = crate::db::open_blobs_read_only(&data_dir.join("blobs.sqlite")) + .map_err(|e| format!("Failed to open the proxy's blob store: {e}"))?; + let settings = ProxySettings::load(&data_dir.join("settings.json"))?; + Ok(Self { db, blobs, capture_body_limit: settings.capture_body_limit }) + } + + pub fn flows(&self, filter: &FlowFilter) -> Result, String> { + flows::list(&self.db, filter) + } + + pub async fn flow(&self, id: &str, body_limit: usize) -> Result { + flows::detail(&self.db, &self.blobs, id, body_limit).await + } + + pub async fn body(&self, id: &str, which: FlowBody) -> Result { + let flow = flows::get(&self.db, id)?; + bodies::read_body(&self.blobs, &flow, which, self.capture_body_limit).await + } + + pub async fn har(&self, filter: &FlowFilter) -> Result { + let flows = flows::list(&self.db, filter)?; + har::export(&self.blobs, &flows, self.capture_body_limit).await + } +} diff --git a/crates-proxy/yaak-proxy-lib/src/control.rs b/crates-proxy/yaak-proxy-lib/src/control.rs new file mode 100644 index 000000000..fca098329 --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/src/control.rs @@ -0,0 +1,632 @@ +//! The control API: how the CLI, MCP tools and a second app launch reach the process that owns +//! the engine. The owner serves JSON on `127.0.0.1:` behind a bearer token and writes +//! where to find it to `control.json` in the proxy data directory (mode 0600). One owner at a +//! time: a file whose process is gone, or whose port no longer answers, is stale and replaced. + +use crate::ca::{self, CaStatus}; +use crate::flows::{self, FlowFilter}; +use crate::har; +use crate::macos::SystemProxyStatus; +use crate::models::{FlowBody, find_header}; +use crate::save::SaveFlowRequest; +use crate::{ProxyCtx, ProxyState, bodies}; +use bytes::Bytes; +use chrono::{DateTime, Utc}; +use http_body_util::{BodyExt, Full, Limited}; +use hyper::body::Incoming; +use hyper::header::{AUTHORIZATION, CONTENT_TYPE}; +use hyper::{Method, Request, Response, StatusCode}; +use hyper_util::rt::TokioIo; +use hyper_util::server::graceful::GracefulShutdown; +use log::{info, warn}; +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::net::{SocketAddr, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::mpsc as std_mpsc; +use std::time::Duration; +use tokio::net::TcpListener; +use ts_rs::TS; +use yaak_proxy::CaStore; + +pub const CONTROL_FILE: &str = "control.json"; +const LOCK_FILE: &str = "control.lock"; +/// Body bytes `GET /flows/{id}` returns per side unless `bodyLimit` says otherwise. +pub const DEFAULT_BODY_PREVIEW_LIMIT: usize = 1024 * 1024; +const MAX_REQUEST_BODY: usize = 64 * 1024; +const LIVENESS_TIMEOUT: Duration = Duration::from_millis(500); +const SHUTDOWN_GRACE: Duration = Duration::from_secs(5); + +/// The process that runs the engine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_rpc.ts")] +pub enum Owner { + App, + Cli, +} + +/// `control.json`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ControlInfo { + pub pid: u32, + pub control_port: u16, + /// The engine's port while it runs. + pub proxy_port: Option, + pub token: String, + pub owner: Owner, + pub started_at: DateTime, +} + +impl ControlInfo { + pub fn base_url(&self) -> String { + format!("http://127.0.0.1:{}", self.control_port) + } +} + +pub fn control_path(data_dir: &Path) -> PathBuf { + data_dir.join(CONTROL_FILE) +} + +/// `control.json` as it is, live or stale. `None` when there is none or it does not parse. +pub fn read(data_dir: &Path) -> Option { + let contents = std::fs::read_to_string(control_path(data_dir)).ok()?; + serde_json::from_str(&contents).ok() +} + +/// Held while `control.json` is checked and then removed or claimed, across processes, so two +/// of them cleaning up a stale file cannot delete each other's fresh claim. Released on drop. +fn claim_lock(data_dir: &Path) -> std::io::Result { + std::fs::create_dir_all(data_dir)?; + let file = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(data_dir.join(LOCK_FILE))?; + file.lock()?; + Ok(file) +} + +/// The owner running the engine now. A stale `control.json` is removed on the way, as is one +/// that does not parse: it is only ever published whole, so that is never a claim in progress. +pub fn live_owner(data_dir: &Path) -> Option { + let _lock = claim_lock(data_dir) + .inspect_err(|e| warn!("Failed to lock {LOCK_FILE}, checking without it: {e}")) + .ok(); + live_owner_locked(data_dir) +} + +/// [`live_owner`] for a caller holding the claim lock. +fn live_owner_locked(data_dir: &Path) -> Option { + let path = control_path(data_dir); + let contents = std::fs::read_to_string(&path).ok()?; + let Ok(info) = serde_json::from_str::(&contents) else { + warn!("Removing unreadable {}", path.display()); + let _ = std::fs::remove_file(&path); + return None; + }; + if is_live(&info) { + return Some(info); + } + info!("Removing stale {CONTROL_FILE} of {:?} pid {}", info.owner, info.pid); + remove_if_token(data_dir, &info.token); + None +} + +/// The process is alive and its control port answers. The port check catches a pid the OS +/// has handed to some other process since the owner died. +fn is_live(info: &ControlInfo) -> bool { + pid_alive(info.pid) + && TcpStream::connect_timeout( + &SocketAddr::from(([127, 0, 0, 1], info.control_port)), + LIVENESS_TIMEOUT, + ) + .is_ok() +} + +#[cfg(unix)] +fn pid_alive(pid: u32) -> bool { + let Ok(pid) = libc::pid_t::try_from(pid) else { + return false; + }; + // Signal 0 checks the process exists without signalling it; EPERM means it exists but is + // someone else's. + let alive = unsafe { libc::kill(pid, 0) } == 0; + alive || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +#[cfg(not(unix))] +fn pid_alive(_pid: u32) -> bool { + // The control port check decides. + true +} + +/// Removes `control.json` if it is still the one with `token`, so an owner never removes its +/// successor's file. The caller holds the claim lock. +fn remove_if_token(data_dir: &Path, token: &str) { + if read(data_dir).is_some_and(|info| info.token == token) { + let _ = std::fs::remove_file(control_path(data_dir)); + } +} + +/// Writes `info` to `path`, readable by its owner alone and never seen half-written. With +/// `create_new`, fails when a file is already there, so two processes cannot both claim the +/// engine. +fn write_file(path: &Path, info: &ControlInfo, create_new: bool) -> std::io::Result<()> { + let contents = serde_json::to_vec_pretty(info)?; + let tmp = path.with_extension(format!("{}.tmp", std::process::id())); + let _ = std::fs::remove_file(&tmp); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + options.open(&tmp)?.write_all(&contents)?; + let published = if create_new { + // A link, unlike a rename, refuses to replace an existing file. + std::fs::hard_link(&tmp, path).and_then(|_| std::fs::remove_file(&tmp)) + } else { + std::fs::rename(&tmp, path) + }; + if published.is_err() { + let _ = std::fs::remove_file(&tmp); + } + published +} + +/// The owner's `control.json`, rewritten as the engine starts and stops. +pub(crate) struct ControlFile { + path: PathBuf, + pub(crate) info: ControlInfo, +} + +impl ControlFile { + pub(crate) fn set_proxy_port(&mut self, port: Option) { + if self.info.proxy_port == port { + return; + } + self.info.proxy_port = port; + if let Err(e) = write_file(&self.path, &self.info, false) { + warn!("Failed to update {}: {e}", self.path.display()); + } + } +} + +// -- Responses -- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ControlStatus { + pub owner: Owner, + pub pid: u32, + pub started_at: DateTime, + pub state: ProxyState, + /// The engine's port while it runs. + pub proxy_port: Option, + /// The port the engine starts on. + pub configured_port: u16, + /// Flows are stored. Paused, traffic still passes through, unrecorded. + pub recording: bool, + pub flow_count: usize, + pub ca: CaStatus, + /// `None` when it could not be read. + pub system_proxy: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct StartRequest { + /// For this run only; `settings.json` keeps its port. + pub port: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemProxyRequest { + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecordingRequest { + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClearResponse { + /// How many flows were deleted. + pub cleared: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorBody { + pub error: String, +} + +/// Raw body headers: whether the body is cut short, at the capture limit or on the wire. +pub const TRUNCATED_HEADER: &str = "x-yaak-truncated"; + +// -- Server -- + +/// The control server of the process that owns the engine. Dropping it stops serving and +/// removes `control.json`. +pub struct ControlServer { + info: ControlInfo, + data_dir: PathBuf, + ctx: ProxyCtx, + shutdown_tx: Option>, + thread: Option>, + stop_rx: Option>, +} + +impl ControlServer { + /// Claims the engine for this process and serves the control API for `ctx`. Fails when + /// another live owner has it. + pub fn start(ctx: ProxyCtx) -> Result { + let data_dir = ctx.data_dir().to_path_buf(); + let path = control_path(&data_dir); + if let Some(owner) = live_owner(&data_dir) { + return Err(already_owned(&owner)); + } + + let (ready_tx, ready_rx) = std_mpsc::channel(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let (stop_tx, stop_rx) = std_mpsc::channel(); + let token = yaak_database::generate_id_of_length(40); + let state = Arc::new(ServerState { ctx: ctx.clone(), token: token.clone(), stop_tx }); + + let thread = std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .thread_name("yaak-proxy-control") + .build() + { + Ok(rt) => rt, + Err(e) => { + let _ = ready_tx.send(Err(format!("Failed to create runtime: {e}"))); + return; + } + }; + rt.block_on(async move { + let listener = match TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await + { + Ok(l) => l, + Err(e) => { + let _ = ready_tx.send(Err(format!("Failed to bind control port: {e}"))); + return; + } + }; + let _ = ready_tx + .send(listener.local_addr().map(|a| a.port()).map_err(|e| e.to_string())); + serve(listener, state, shutdown_rx).await; + }); + }); + + let control_port = match ready_rx.recv() { + Ok(Ok(port)) => port, + Ok(Err(e)) => return Err(e), + Err(_) => return Err("Control thread died before binding".into()), + }; + let info = ControlInfo { + pid: std::process::id(), + control_port, + proxy_port: ctx.port(), + token, + owner: ctx.owner(), + started_at: Utc::now(), + }; + let mut server = Self { + info: info.clone(), + data_dir: data_dir.clone(), + ctx: ctx.clone(), + shutdown_tx: Some(shutdown_tx), + thread: Some(thread), + stop_rx: Some(stop_rx), + }; + + // Checked again under the lock: another process may have claimed it since. + let claimed = claim_lock(&data_dir) + .map_err(|e| format!("Failed to lock {LOCK_FILE}: {e}")) + .and_then(|_lock| match live_owner_locked(&data_dir) { + Some(owner) => Err(already_owned(&owner)), + None => write_file(&path, &info, true) + .map_err(|e| format!("Failed to write {}: {e}", path.display())), + }); + if let Err(e) = claimed { + // Nothing of this server's to remove. + server.info.token.clear(); + return Err(e); + } + ctx.set_control_file(Some(ControlFile { path, info })); + info!("Control API listening on 127.0.0.1:{control_port}"); + Ok(server) + } + + pub fn info(&self) -> &ControlInfo { + &self.info + } + + /// Receives once per `POST /stop`. A CLI daemon exits on it; the app only stops its engine. + pub fn take_stop_rx(&mut self) -> Option> { + self.stop_rx.take() + } +} + +pub(crate) fn already_owned(owner: &ControlInfo) -> String { + format!("The proxy is already running in {} (pid {})", owner_name(owner.owner), owner.pid) +} + +pub(crate) fn owner_name(owner: Owner) -> &'static str { + match owner { + Owner::App => "Yaak Proxy", + Owner::Cli => "the yaak CLI", + } +} + +impl Drop for ControlServer { + fn drop(&mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + if !self.info.token.is_empty() { + self.ctx.set_control_file(None); + let _lock = claim_lock(&self.data_dir).ok(); + remove_if_token(&self.data_dir, &self.info.token); + } + } +} + +struct ServerState { + ctx: ProxyCtx, + token: String, + stop_tx: std_mpsc::Sender<()>, +} + +async fn serve( + listener: TcpListener, + state: Arc, + mut shutdown_rx: tokio::sync::oneshot::Receiver<()>, +) { + // Lets a request in flight at shutdown finish, such as the `POST /stop` a daemon exits on. + let graceful = GracefulShutdown::new(); + loop { + tokio::select! { + result = listener.accept() => match result { + Ok((stream, _)) => { + let state = state.clone(); + let service = hyper::service::service_fn(move |req| { + let state = state.clone(); + async move { Ok::<_, std::convert::Infallible>(handle(&state, req).await) } + }); + let conn = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(stream), service); + let conn = graceful.watch(conn); + tokio::spawn(async move { + let _ = conn.await; + }); + } + Err(e) => warn!("Control accept error: {e}"), + }, + _ = &mut shutdown_rx => break, + } + } + drop(listener); + if tokio::time::timeout(SHUTDOWN_GRACE, graceful.shutdown()).await.is_err() { + warn!("Control connections were still open after {SHUTDOWN_GRACE:?}"); + } +} + +type ControlResponse = Response>; + +struct ApiError { + status: StatusCode, + message: String, +} + +impl ApiError { + fn new(status: StatusCode, message: impl Into) -> Self { + Self { status, message: message.into() } + } +} + +impl From for ApiError { + fn from(message: String) -> Self { + Self::new(StatusCode::INTERNAL_SERVER_ERROR, message) + } +} + +async fn handle(state: &ServerState, req: Request) -> ControlResponse { + if !authorized(&req, &state.token) { + return error_response(ApiError::new( + StatusCode::UNAUTHORIZED, + "Missing or wrong bearer token; it is in control.json", + )); + } + match route(state, req).await { + Ok(resp) => resp, + Err(e) => error_response(e), + } +} + +fn authorized(req: &Request, token: &str) -> bool { + let Some(given) = req + .headers() + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + else { + return false; + }; + // Compares every byte, so the time taken says nothing about how much matched. + given.len() == token.len() + && given.bytes().zip(token.bytes()).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0 +} + +async fn route(state: &ServerState, req: Request) -> Result { + let ctx = &state.ctx; + let method = req.method().clone(); + let path = req.uri().path().to_string(); + let query = req.uri().query().unwrap_or_default().to_string(); + let segments: Vec<&str> = path.trim_matches('/').split('/').collect(); + + match (&method, segments.as_slice()) { + (&Method::GET, ["status"]) => json(&status(ctx).await?), + (&Method::POST, ["start"]) => { + let body: StartRequest = read_json(req).await?; + ctx.start_on(body.port)?; + json(&status(ctx).await?) + } + (&Method::POST, ["stop"]) => { + ctx.stop()?; + let _ = state.stop_tx.send(()); + json(&status(ctx).await?) + } + (&Method::POST, ["recording"]) => { + let body: RecordingRequest = read_json(req).await?; + ctx.set_recording(body.enabled); + json(&status(ctx).await?) + } + (&Method::GET, ["flows"]) => { + let filter = FlowFilter::from_query(&query).map_err(bad_request)?; + json(&flows::list(&ctx.db, &filter)?) + } + (&Method::POST, ["flows", "clear"]) => { + let ctx = ctx.clone(); + json(&ClearResponse { cleared: blocking(move || ctx.clear_flows()).await? }) + } + (&Method::GET, ["flows", id]) => { + found(ctx, id)?; + let limit = body_limit(&query)?; + json(&flows::detail(&ctx.db, &ctx.blobs, id, limit).await?) + } + (&Method::GET, ["flows", id, "request-body"]) => raw_body(ctx, id, FlowBody::Request).await, + (&Method::GET, ["flows", id, "response-body"]) => { + raw_body(ctx, id, FlowBody::Response).await + } + (&Method::POST, ["flows", id, "save"]) => { + found(ctx, id)?; + let id = id.to_string(); + let body: SaveFlowRequest = read_json(req).await?; + json(&ctx.save_flow(&id, &body).await.map_err(bad_request)?) + } + (&Method::GET, ["har"]) => { + let filter = FlowFilter::from_query(&query).map_err(bad_request)?; + let flows = flows::list(&ctx.db, &filter)?; + json(&har::export(&ctx.blobs, &flows, ctx.capture_body_limit()?).await?) + } + (&Method::GET, ["ca"]) => { + let ca = CaStore::load_or_create(&ctx.ca_dir()) + .map_err(|e| format!("Failed to load proxy CA: {e}"))?; + Ok(response(StatusCode::OK, "application/x-pem-file", ca.pem().to_string())) + } + (&Method::POST, ["ca", "trust"]) => { + let ca = CaStore::load_or_create(&ctx.ca_dir()) + .map_err(|e| format!("Failed to load proxy CA: {e}"))?; + ca::trust(ca.cert_path()).await.map_err(bad_request)?; + ctx.forget_passthrough(); + json(&ca::status(ca.cert_path()).await?) + } + (&Method::GET, ["system-proxy"]) => { + let ctx = ctx.clone(); + json(&blocking(move || ctx.system_proxy_status()).await?) + } + (&Method::POST, ["system-proxy"]) => { + let body: SystemProxyRequest = read_json(req).await?; + let ctx = ctx.clone(); + json(&blocking(move || ctx.set_system_proxy(body.enabled)).await.map_err(bad_request)?) + } + (&Method::GET, ["workspaces"]) => json(&ctx.workspaces().map_err(bad_request)?), + _ => Err(ApiError::new(StatusCode::NOT_FOUND, format!("No route {method} {path}"))), + } +} + +async fn status(ctx: &ProxyCtx) -> Result { + let control = + ctx.control_info().ok_or_else(|| "The control API is shutting down".to_string())?; + let proxy_port = ctx.port(); + let ca = ca::status(&ctx.ca_dir().join("ca.pem")).await?; + let system_proxy = { + let ctx = ctx.clone(); + blocking(move || ctx.system_proxy_status()).await.ok() + }; + Ok(ControlStatus { + owner: control.owner, + pid: control.pid, + started_at: control.started_at, + state: if proxy_port.is_some() { ProxyState::Running } else { ProxyState::Stopped }, + proxy_port, + configured_port: ctx.settings()?.port, + recording: ctx.recording(), + flow_count: flows::count(&ctx.db)?, + ca, + system_proxy, + }) +} + +fn found(ctx: &ProxyCtx, id: &str) -> Result<(), ApiError> { + match flows::find(&ctx.db, id) { + Some(_) => Ok(()), + None => Err(ApiError::new(StatusCode::NOT_FOUND, format!("No flow {id}"))), + } +} + +async fn raw_body(ctx: &ProxyCtx, id: &str, which: FlowBody) -> Result { + let flow = flows::find(&ctx.db, id) + .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, format!("No flow {id}")))?; + let body = bodies::read_body(&ctx.blobs, &flow, which, ctx.capture_body_limit()?).await?; + let content_type = + find_header(flow.headers(which), "content-type").unwrap_or("application/octet-stream"); + let mut resp = response(StatusCode::OK, content_type, body.data); + resp.headers_mut().insert(TRUNCATED_HEADER, body.truncated.to_string().parse().unwrap()); + Ok(resp) +} + +fn body_limit(query: &str) -> Result { + for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { + if key == "bodyLimit" { + return value.parse().map_err(|_| bad_request(format!("Invalid bodyLimit {value:?}"))); + } + } + Ok(DEFAULT_BODY_PREVIEW_LIMIT) +} + +async fn blocking( + f: impl FnOnce() -> Result + Send + 'static, +) -> Result { + tokio::task::spawn_blocking(f).await.map_err(|e| e.to_string())? +} + +/// The JSON request body; an empty one reads as `{}`. +async fn read_json(req: Request) -> Result { + let bytes = Limited::new(req.into_body(), MAX_REQUEST_BODY) + .collect() + .await + .map_err(|e| bad_request(format!("Failed to read the request body: {e}")))? + .to_bytes(); + let bytes: &[u8] = if bytes.iter().all(u8::is_ascii_whitespace) { b"{}" } else { &bytes }; + serde_json::from_slice(bytes).map_err(|e| bad_request(format!("Invalid JSON body: {e}"))) +} + +fn bad_request(message: String) -> ApiError { + ApiError::new(StatusCode::BAD_REQUEST, message) +} + +fn json(value: &T) -> Result { + let body = serde_json::to_vec(value).map_err(|e| e.to_string())?; + Ok(response(StatusCode::OK, "application/json", body)) +} + +fn error_response(e: ApiError) -> ControlResponse { + let body = serde_json::to_vec(&ErrorBody { error: e.message }).unwrap_or_default(); + response(e.status, "application/json", body) +} + +fn response(status: StatusCode, content_type: &str, body: impl Into) -> ControlResponse { + let mut resp = Response::new(Full::new(body.into())); + *resp.status_mut() = status; + if let Ok(value) = content_type.parse() { + resp.headers_mut().insert(CONTENT_TYPE, value); + } + resp +} diff --git a/crates-proxy/yaak-proxy-lib/src/convert.rs b/crates-proxy/yaak-proxy-lib/src/convert.rs new file mode 100644 index 000000000..1c58c232f --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/src/convert.rs @@ -0,0 +1,462 @@ +//! A captured flow as a Yaak request. Headers Yaak sets itself when sending, and hop-by-hop +//! headers that only meant something between the app and the proxy, are dropped and listed so +//! the save dialog can say why. Authorization is kept as the plain header it was captured as. + +use crate::bodies::DecodedBody; +use crate::models::{Flow, find_header}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::BTreeMap; +use ts_rs::TS; +use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter}; + +// The client's body types (`apps/yaak-client/lib/model_util.ts`). +const BODY_TYPE_JSON: &str = "application/json"; +const BODY_TYPE_XML: &str = "text/xml"; +const BODY_TYPE_GRAPHQL: &str = "graphql"; +const BODY_TYPE_FORM_URLENCODED: &str = "application/x-www-form-urlencoded"; +const BODY_TYPE_OTHER: &str = "other"; + +/// Removed between the app and the proxy; never forwarded as they are. +const HOP_BY_HOP: &[&str] = &[ + "connection", + "keep-alive", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; +/// Yaak works these out itself when it sends the request. +const AUTOMATIC: &[&str] = &["host", "content-length", "accept-encoding"]; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct DroppedHeader { + pub name: String, + pub value: String, + pub reason: DropReason, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_rpc.ts")] +pub enum DropReason { + /// Only meant for the hop between the app and the proxy. + HopByHop, + /// Yaak sets it when it sends the request. + SetBySending, + /// The body is saved decoded, so its encoding no longer applies. + BodyDecoded, +} + +#[derive(Debug, Clone)] +pub struct Conversion { + pub request: HttpRequest, + pub dropped_headers: Vec, + /// What the saved request does not carry over exactly, one sentence each. + pub warnings: Vec, +} + +/// `flow` as a request in no workspace yet, with `body` its request body decoded. +pub fn flow_to_request(flow: &Flow, body: &DecodedBody) -> Conversion { + let mut warnings = Vec::new(); + let (url, url_parameters) = split_query(&flow.url); + let (headers, dropped_headers) = split_headers(flow, body); + let has_authorization = headers.iter().any(|h| h.name.eq_ignore_ascii_case("authorization")); + if has_authorization { + warnings.push("The Authorization header is saved as captured, in plain text.".into()); + } + let (body_type, body_fields) = request_body(flow, body, &mut warnings); + if body.truncated { + warnings.push(format!( + "The body was cut at the capture limit, so only its first {} bytes are saved.", + body.data.len() + )); + } + + // No name, as in Yaak, which then shows the URL. + let request = HttpRequest { + method: flow.method.clone(), + url, + url_parameters, + headers, + body_type, + body: body_fields, + // No auth rather than inherited, so a folder's or workspace's auth cannot replace the + // captured header when the request is sent. + authentication_type: has_authorization.then(|| "none".to_string()), + ..Default::default() + }; + Conversion { request, dropped_headers, warnings } +} + +/// The URL without its query, and the query as parameters. +fn split_query(url: &str) -> (String, Vec) { + let (url, fragment) = match url.split_once('#') { + Some((url, fragment)) => (url, Some(fragment)), + None => (url, None), + }; + let Some((base, query)) = url.split_once('?') else { + return (url.to_string() + &fragment.map(|f| format!("#{f}")).unwrap_or_default(), vec![]); + }; + let params = url::form_urlencoded::parse(query.as_bytes()) + .map(|(name, value)| HttpUrlParameter { + enabled: true, + name: name.into_owned(), + value: value.into_owned(), + id: None, + }) + .collect(); + (base.to_string() + &fragment.map(|f| format!("#{f}")).unwrap_or_default(), params) +} + +fn split_headers(flow: &Flow, body: &DecodedBody) -> (Vec, Vec) { + // Connection names further headers that are hop-by-hop for this request alone. + let listed: Vec = flow + .request_headers + .iter() + .filter(|h| h.name.eq_ignore_ascii_case("connection")) + .flat_map(|h| h.value.split(',')) + .map(|n| n.trim().to_ascii_lowercase()) + .filter(|n| !n.is_empty()) + .collect(); + let encoding = find_header(&flow.request_headers, "content-encoding"); + // A body that decodes to nothing was still decoded. + let decoded = body.stored_size > 0 + && encoding.is_some_and(|e| !e.trim().eq_ignore_ascii_case("identity")); + + let mut kept = Vec::new(); + let mut dropped = Vec::new(); + for h in &flow.request_headers { + let name = h.name.to_ascii_lowercase(); + let reason = if HOP_BY_HOP.contains(&name.as_str()) + || name.starts_with("proxy-") + || name.starts_with(':') + || listed.contains(&name) + { + Some(DropReason::HopByHop) + } else if AUTOMATIC.contains(&name.as_str()) { + Some(DropReason::SetBySending) + } else if name == "content-encoding" && decoded { + Some(DropReason::BodyDecoded) + } else { + None + }; + match reason { + Some(reason) => { + dropped.push(DroppedHeader { name: h.name.clone(), value: h.value.clone(), reason }) + } + None => kept.push(HttpRequestHeader { + enabled: true, + name: h.name.clone(), + value: h.value.clone(), + id: None, + }), + } + } + (kept, dropped) +} + +fn request_body( + flow: &Flow, + body: &DecodedBody, + warnings: &mut Vec, +) -> (Option, BTreeMap) { + if body.data.is_empty() { + return (None, BTreeMap::new()); + } + let content_type = find_header(&flow.request_headers, "content-type").unwrap_or_default(); + let mime = content_type.split(';').next().unwrap_or_default().trim().to_ascii_lowercase(); + let Ok(text) = std::str::from_utf8(&body.data) else { + warnings.push( + "The body is binary and is saved as text, so bytes that are not UTF-8 are replaced." + .into(), + ); + return text_body(BODY_TYPE_OTHER, &String::from_utf8_lossy(&body.data)); + }; + + if mime == "application/json" || mime.ends_with("+json") { + if let Some(graphql) = graphql_body(flow, text) { + return (Some(BODY_TYPE_GRAPHQL.into()), graphql); + } + return text_body(BODY_TYPE_JSON, text); + } + if mime == "application/xml" || mime == "text/xml" || mime.ends_with("+xml") { + return text_body(BODY_TYPE_XML, text); + } + if mime == BODY_TYPE_FORM_URLENCODED { + let form: Vec = url::form_urlencoded::parse(text.as_bytes()) + .map(|(name, value)| json!({ "enabled": true, "name": name, "value": value })) + .collect(); + return ( + Some(BODY_TYPE_FORM_URLENCODED.into()), + BTreeMap::from([("form".into(), json!(form))]), + ); + } + if mime == "multipart/form-data" { + warnings.push( + "The multipart body is saved as text with its Content-Type, not as form fields.".into(), + ); + } + text_body(BODY_TYPE_OTHER, text) +} + +fn text_body(body_type: &str, text: &str) -> (Option, BTreeMap) { + (Some(body_type.into()), BTreeMap::from([("text".into(), json!(text))])) +} + +/// A JSON body posted to a GraphQL endpoint, as the client's GraphQL body. +fn graphql_body(flow: &Flow, text: &str) -> Option> { + let path = flow.url.split('?').next().unwrap_or_default(); + if !path.to_ascii_lowercase().ends_with("/graphql") { + return None; + } + let Value::Object(body) = serde_json::from_str::(text).ok()? else { + return None; + }; + // Anything else, such as persisted-query `extensions`, would be lost in a GraphQL body. + if body.keys().any(|k| !matches!(k.as_str(), "query" | "variables" | "operationName")) { + return None; + } + let query = body.get("query")?.as_str()?; + let mut fields = BTreeMap::from([("query".to_string(), json!(query))]); + if let Some(variables) = body.get("variables").filter(|v| !v.is_null()) { + let pretty = serde_json::to_string_pretty(variables).ok()?; + fields.insert("variables".into(), json!(pretty)); + } + if let Some(name) = body.get("operationName").and_then(Value::as_str) { + fields.insert("operationName".into(), json!(name)); + } + Some(fields) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::ProxyHeader; + + fn flow(method: &str, url: &str, headers: &[(&str, &str)]) -> Flow { + Flow { + method: method.into(), + url: url.into(), + request_headers: headers + .iter() + .map(|(n, v)| ProxyHeader { name: (*n).into(), value: (*v).into() }) + .collect(), + ..Default::default() + } + } + + fn body(data: &str) -> DecodedBody { + DecodedBody { + data: data.as_bytes().to_vec(), + stored_size: data.len(), + truncated: false, + decode_error: None, + } + } + + fn names(request: &HttpRequest) -> Vec<&str> { + request.headers.iter().map(|h| h.name.as_str()).collect() + } + + #[test] + fn get_without_body() { + let f = flow( + "GET", + "https://api.github.com/search/issues?q=is%3Aopen+label%3Abug&per_page=5", + &[ + ("Host", "api.github.com"), + ("Accept", "application/json"), + ("Accept-Encoding", "gzip, br"), + ("Connection", "keep-alive, X-Debug"), + ("X-Debug", "1"), + ("Proxy-Authorization", "Basic Zm9v"), + ("Authorization", "Bearer ghp_secret"), + ], + ); + let c = flow_to_request(&f, &body("")); + let r = &c.request; + assert_eq!(r.method, "GET"); + assert_eq!(r.url, "https://api.github.com/search/issues"); + assert_eq!(r.name, ""); + let params: Vec<(&str, &str)> = + r.url_parameters.iter().map(|p| (p.name.as_str(), p.value.as_str())).collect(); + assert_eq!(params, vec![("q", "is:open label:bug"), ("per_page", "5")]); + assert_eq!(names(r), vec!["Accept", "Authorization"]); + assert_eq!(r.headers[1].value, "Bearer ghp_secret"); + assert_eq!(r.authentication_type.as_deref(), Some("none")); + assert_eq!(r.body_type, None); + assert!(r.body.is_empty()); + + let dropped: Vec<(&str, DropReason)> = + c.dropped_headers.iter().map(|d| (d.name.as_str(), d.reason)).collect(); + assert_eq!( + dropped, + vec![ + ("Host", DropReason::SetBySending), + ("Accept-Encoding", DropReason::SetBySending), + ("Connection", DropReason::HopByHop), + ("X-Debug", DropReason::HopByHop), + ("Proxy-Authorization", DropReason::HopByHop), + ] + ); + assert_eq!( + c.warnings, + vec!["The Authorization header is saved as captured, in plain text."] + ); + } + + #[test] + fn json_body() { + let f = flow( + "POST", + "https://api.stripe.com/v1/charges", + &[ + ("Content-Type", "application/json; charset=utf-8"), + ("Content-Length", "13"), + ], + ); + let c = flow_to_request(&f, &body(r#"{"amount":42}"#)); + assert_eq!(c.request.body_type.as_deref(), Some("application/json")); + assert_eq!(c.request.body["text"], r#"{"amount":42}"#); + assert_eq!(names(&c.request), vec!["Content-Type"]); + assert!(c.warnings.is_empty()); + // Without a captured Authorization, the request inherits its folder's auth. + assert_eq!(c.request.authentication_type, None); + } + + #[test] + fn vendor_json_body() { + let f = flow("PATCH", "https://x.test/a", &[("Content-Type", "application/vnd.api+json")]); + let c = flow_to_request(&f, &body("{}")); + assert_eq!(c.request.body_type.as_deref(), Some("application/json")); + } + + #[test] + fn graphql_body() { + let f = + flow("POST", "https://api.github.com/graphql", &[("Content-Type", "application/json")]); + let c = flow_to_request( + &f, + &body( + r#"{"query":"query($n:Int){viewer{login}}","variables":{"n":1},"operationName":"Me"}"#, + ), + ); + assert_eq!(c.request.body_type.as_deref(), Some("graphql")); + assert_eq!(c.request.body["query"], "query($n:Int){viewer{login}}"); + assert_eq!(c.request.body["variables"], "{\n \"n\": 1\n}"); + assert_eq!(c.request.body["operationName"], "Me"); + + // Fields a GraphQL body cannot hold keep it plain JSON. + let persisted = r#"{"query":"q","extensions":{"persistedQuery":{"version":1}}}"#; + let c = flow_to_request(&f, &body(persisted)); + assert_eq!(c.request.body_type.as_deref(), Some("application/json")); + + // The same body anywhere else is plain JSON. + let f = flow("POST", "https://x.test/api", &[("Content-Type", "application/json")]); + let c = flow_to_request(&f, &body(r#"{"query":"q"}"#)); + assert_eq!(c.request.body_type.as_deref(), Some("application/json")); + } + + #[test] + fn xml_body() { + let f = flow("POST", "https://x.test/soap", &[("Content-Type", "application/soap+xml")]); + let c = flow_to_request(&f, &body("")); + assert_eq!(c.request.body_type.as_deref(), Some("text/xml")); + assert_eq!(c.request.body["text"], ""); + } + + #[test] + fn form_body() { + let f = flow( + "POST", + "http://localhost:3000/login", + &[("Content-Type", "application/x-www-form-urlencoded")], + ); + let c = flow_to_request(&f, &body("user=greg¬e=hello+world%21")); + assert_eq!(c.request.body_type.as_deref(), Some("application/x-www-form-urlencoded")); + assert_eq!( + c.request.body["form"], + json!([ + { "enabled": true, "name": "user", "value": "greg" }, + { "enabled": true, "name": "note", "value": "hello world!" }, + ]) + ); + assert_eq!(c.request.name, "", "unnamed, so Yaak shows its URL"); + } + + #[test] + fn multipart_body() { + let content_type = "multipart/form-data; boundary=XyZ"; + let f = flow("POST", "https://x.test/upload", &[("Content-Type", content_type)]); + let text = "--XyZ\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\n1\r\n--XyZ--\r\n"; + let c = flow_to_request(&f, &body(text)); + assert_eq!(c.request.body_type.as_deref(), Some("other")); + assert_eq!(c.request.body["text"], text); + assert_eq!(c.request.headers[0].value, content_type); + assert_eq!(c.warnings.len(), 1); + } + + #[test] + fn text_body() { + let f = flow("PUT", "https://x.test/", &[("Content-Type", "text/plain")]); + let c = flow_to_request(&f, &body("hello")); + assert_eq!(c.request.body_type.as_deref(), Some("other")); + assert_eq!(c.request.body["text"], "hello"); + assert_eq!(c.request.name, ""); + } + + #[test] + fn binary_body() { + let f = flow("POST", "https://x.test/bin", &[("Content-Type", "application/octet-stream")]); + let data = DecodedBody { + data: vec![b'a', 0xff, b'b'], + stored_size: 3, + truncated: false, + decode_error: None, + }; + let c = flow_to_request(&f, &data); + assert_eq!(c.request.body_type.as_deref(), Some("other")); + assert_eq!(c.request.body["text"], "a\u{fffd}b"); + assert_eq!(c.warnings.len(), 1); + } + + #[test] + fn decoded_empty_body() { + let f = flow("POST", "https://x.test/", &[("Content-Encoding", "gzip")]); + // A gzip stream of nothing: stored bytes, but an empty body once decoded. + let data = + DecodedBody { data: Vec::new(), stored_size: 20, truncated: false, decode_error: None }; + let c = flow_to_request(&f, &data); + assert!(c.request.headers.is_empty()); + assert_eq!(c.dropped_headers[0].reason, DropReason::BodyDecoded); + assert_eq!(c.request.body_type, None); + } + + #[test] + fn decoded_and_truncated_body() { + let f = flow( + "POST", + "https://x.test/logs", + &[ + ("Content-Type", "application/json"), + ("Content-Encoding", "gzip"), + ], + ); + let data = DecodedBody { + data: b"[1,2".to_vec(), + stored_size: 4, + truncated: true, + decode_error: None, + }; + let c = flow_to_request(&f, &data); + assert_eq!(names(&c.request), vec!["Content-Type"]); + assert_eq!(c.dropped_headers[0].reason, DropReason::BodyDecoded); + assert_eq!( + c.warnings, + vec!["The body was cut at the capture limit, so only its first 4 bytes are saved."] + ); + } +} diff --git a/crates-proxy/yaak-proxy-lib/src/db.rs b/crates-proxy/yaak-proxy-lib/src/db.rs index e5bba29d2..dcbb88a59 100644 --- a/crates-proxy/yaak-proxy-lib/src/db.rs +++ b/crates-proxy/yaak-proxy-lib/src/db.rs @@ -1,31 +1,216 @@ use include_dir::{Dir, include_dir}; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; +use rusqlite::functions::FunctionFlags; +use rusqlite::{OpenFlags, Transaction, TransactionBehavior}; use std::path::Path; -use yaak_database::{ConnectionOrTx, DbContext, run_migrations}; +use std::time::Duration; +use yaak_database::{ConnectionOrTx, DbContext, Error, Result, SqlitePool, run_migrations}; +use yaak_models::blob_manager::BlobManager; static MIGRATIONS: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/migrations"); +/// Reads come from a pool; writes go through one connection, as in yaak-models' +/// `QueryManager`. WAL lets the CLI read `proxy.db` while the app writes it. #[derive(Clone)] pub struct ProxyQueryManager { - pool: Pool, + readers: SqlitePool, + writer: SqlitePool, } impl ProxyQueryManager { - pub fn new(db_path: &Path) -> Self { - let manager = SqliteConnectionManager::file(db_path); - let pool = - Pool::builder().max_size(5).build(manager).expect("Failed to create proxy DB pool"); - run_migrations(&pool, &MIGRATIONS).expect("Failed to run proxy DB migrations"); - Self { pool } + pub fn new(db_path: &Path) -> Result { + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent)?; + } + let writer = file_pool(db_path, 1)?; + run_migrations(&writer, &MIGRATIONS)?; + Ok(Self { readers: file_pool(db_path, 5)?, writer }) + } + + /// `proxy.db` for reading while another process may be writing it, or none is. Writes + /// fail. No migrations run, so an older database reads as it is. + pub fn open_read_only(db_path: &Path) -> Result { + let readers = read_only_pool(db_path)?; + Ok(Self { writer: readers.clone(), readers }) } pub fn with_conn(&self, func: F) -> T where F: FnOnce(&DbContext) -> T, { - let conn = self.pool.get().expect("Failed to get proxy DB connection"); + let conn = self.readers.get().expect("Failed to get proxy DB connection"); let ctx = DbContext::new(ConnectionOrTx::Connection(conn)); func(&ctx) } + + /// Run `func` in a transaction on the writer connection. + pub fn with_tx( + &self, + func: impl FnOnce(&DbContext) -> std::result::Result, + ) -> std::result::Result + where + E: From, + { + let conn = self.writer.get().map_err(Error::from)?; + let tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate) + .map_err(Error::from)?; + let ctx = DbContext::new(ConnectionOrTx::Transaction(&tx)); + let result = func(&ctx); + drop(ctx); + match result { + Ok(val) => { + tx.commit().map_err(Error::from)?; + Ok(val) + } + Err(e) => { + tx.rollback().map_err(Error::from)?; + Err(e) + } + } + } +} + +/// `blobs.sqlite` for reading, alongside [`ProxyQueryManager::open_read_only`]. +pub fn open_blobs_read_only(path: &Path) -> Result { + let readers = read_only_pool(path)?; + Ok(BlobManager::new(readers.clone(), readers)) +} + +/// `sidebar_path(url)`, the app sidebar's place for a URL, for filtering by its nodes. +fn add_functions(conn: &mut rusqlite::Connection) -> rusqlite::Result<()> { + conn.create_scalar_function( + "sidebar_path", + 1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |ctx| Ok(crate::flows::sidebar_path(&ctx.get::(0)?)), + ) +} + +fn read_only_pool(path: &Path) -> Result { + let manager = SqliteConnectionManager::file(path) + .with_flags(OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX) + .with_init(|conn| { + conn.busy_timeout(Duration::from_millis(5000))?; + add_functions(conn) + }); + Pool::builder() + .max_size(2) + .connection_timeout(Duration::from_secs(10)) + .build(manager) + .map_err(|e| Error::Database(e.to_string())) +} + +fn file_pool(path: &Path, max_size: u32) -> Result { + let manager = SqliteConnectionManager::file(path).with_init(|conn| { + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "synchronous", "NORMAL")?; + conn.busy_timeout(Duration::from_millis(5000))?; + add_functions(conn) + }); + Pool::builder() + .max_size(max_size) + .connection_timeout(Duration::from_secs(10)) + .build(manager) + .map_err(|e| Error::Database(e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + fn columns(conn: &Connection, table: &str) -> Vec { + let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})")).unwrap(); + stmt.query_map([], |r| r.get::<_, String>("name")).unwrap().map(|c| c.unwrap()).collect() + } + + #[test] + fn reads_flows_read_only_before_migrations() { + use crate::models::Flow; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("proxy.db"); + drop(ProxyQueryManager::new(&path).unwrap()); + + // As a build from before the saved request and client columns left it. + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "ALTER TABLE flows DROP COLUMN saved_request_id; + ALTER TABLE flows DROP COLUMN client_pid; + ALTER TABLE flows DROP COLUMN client_name; + ALTER TABLE flows DROP COLUMN client_bundle_id; + ALTER TABLE flows DROP COLUMN client_path; + ALTER TABLE flows DROP COLUMN cancelled; + INSERT INTO flows (id, url, method) VALUES ('fl_old', 'https://example.com', 'GET');", + ) + .unwrap(); + drop(conn); + + let db = ProxyQueryManager::open_read_only(&path).unwrap(); + let flows = db.with_conn(|c| c.find_all::()).unwrap(); + assert_eq!(flows.len(), 1); + assert_eq!((flows[0].id.as_str(), flows[0].saved_request_id.as_deref()), ("fl_old", None)); + assert_eq!((flows[0].client_pid, flows[0].client_name.as_deref()), (None, None)); + assert!(!flows[0].cancelled); + } + + #[test] + fn migrates_an_old_schema_db() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("proxy.db"); + + // The database as the first migration left it, with a row in it. + let conn = Connection::open(&path).unwrap(); + conn.execute_batch(include_str!("../migrations/00000001_init.sql")).unwrap(); + conn.execute_batch( + "CREATE TABLE _sqlx_migrations ( + version TEXT PRIMARY KEY, + description TEXT NOT NULL, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL + ); + INSERT INTO _sqlx_migrations (version, description) + VALUES ('00000001', '00000001_init.sql'); + INSERT INTO http_exchanges (id, url, method, req_body, res_body) + VALUES ('he_old', 'https://example.com', 'GET', x'00', x'01');", + ) + .unwrap(); + drop(conn); + + let db = ProxyQueryManager::new(&path).unwrap(); + drop(db); + + let conn = Connection::open(&path).unwrap(); + let tables: Vec = conn + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") + .unwrap() + .query_map([], |r| r.get(0)) + .unwrap() + .map(|t| t.unwrap()) + .collect(); + assert_eq!(tables, vec!["_sqlx_migrations", "flows"]); + + let flow_columns = columns(&conn, "flows"); + assert!(flow_columns.contains(&"response_truncated".to_string())); + for column in [ + "client_pid", + "client_name", + "client_bundle_id", + "client_path", + "cancelled", + ] { + assert!(flow_columns.contains(&column.to_string()), "{column} in {flow_columns:?}"); + } + let indexes: Vec = conn + .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'flows' AND sql IS NOT NULL") + .unwrap() + .query_map([], |r| r.get(0)) + .unwrap() + .map(|i| i.unwrap()) + .collect(); + assert_eq!(indexes, vec!["idx_flows_started_at_id"]); + assert!(!flow_columns.iter().any(|c| c.contains("body")), "{flow_columns:?}"); + + let journal_mode: String = conn.query_row("PRAGMA journal_mode", [], |r| r.get(0)).unwrap(); + assert_eq!(journal_mode, "wal"); + } } diff --git a/crates-proxy/yaak-proxy-lib/src/flows.rs b/crates-proxy/yaak-proxy-lib/src/flows.rs new file mode 100644 index 000000000..0ae72f4c5 --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/src/flows.rs @@ -0,0 +1,795 @@ +use crate::bodies; +use crate::db::ProxyQueryManager; +use crate::models::{Flow, FlowBody, FlowIden}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use rusqlite::types::Value; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use yaak_database::UpsertModelInfo; +use yaak_models::blob_manager::BlobManager; + +/// Which flows a listing or a HAR export covers. Every field narrows it; none is all of them. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct FlowFilter { + /// Search terms, any case, each of which must match the URL (host, path or query), the + /// method, the status (`404`, `40`, `4xx`) or the content type: `stripe post 4xx`. + #[ts(optional)] + pub q: Option, + /// Part of the host, any case: `stripe` matches `api.stripe.com`. + #[ts(optional)] + pub host: Option, + /// Only flows under one of these hosts or paths, as the app's sidebar names them: + /// `api.stripe.com` or `api.stripe.com/v1/charges`, with any port after the host. + #[ts(optional)] + pub under: Option>, + /// Only flows from one of these apps, by bundle id, or by path for a process outside an + /// app bundle. + #[ts(optional)] + pub app: Option>, + /// The method, any case. + #[ts(optional)] + pub method: Option, + /// A status (`404`), a class of them (`4xx`), or `error` for flows that failed. + #[ts(optional)] + pub status: Option, + /// At most this many flows, the newest. + #[ts(optional, type = "number")] + pub limit: Option, + /// Only flows recorded after the flow with this id, for following new traffic. Flows are + /// recorded when they end, so a slow one that started earlier still comes through. + #[ts(optional)] + pub after: Option, + /// Only flows older than the flow with this id, by start and then id, for paging back + /// through older traffic. + #[ts(optional)] + pub before: Option, +} + +impl FlowFilter { + /// The filter from a query string's `q`, `host`, `under`, `app`, `method`, `status`, + /// `limit`, `after` and `before`. `under` and `app` may repeat. + pub fn from_query(query: &str) -> Result { + let mut filter = Self::default(); + for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { + let value = Some(value.into_owned()).filter(|v| !v.is_empty()); + match key.as_ref() { + "q" => filter.q = value, + "host" => filter.host = value, + "under" => filter.under.get_or_insert_default().extend(value), + "app" => filter.app.get_or_insert_default().extend(value), + "method" => filter.method = value, + "status" => filter.status = value, + "after" => filter.after = value, + "before" => filter.before = value, + "limit" => { + filter.limit = value + .map(|v| v.parse().map_err(|_| format!("Invalid limit {v:?}"))) + .transpose()? + } + _ => {} + } + } + Ok(filter) + } + + pub fn to_query(&self) -> String { + let mut query = url::form_urlencoded::Serializer::new(String::new()); + let limit = self.limit.map(|l| l.to_string()); + for (key, value) in [("q", &self.q), ("host", &self.host)] { + if let Some(value) = value { + query.append_pair(key, value); + } + } + for (key, values) in [("under", &self.under), ("app", &self.app)] { + for value in values.iter().flatten() { + query.append_pair(key, value); + } + } + for (key, value) in [ + ("method", &self.method), + ("status", &self.status), + ("limit", &limit), + ("after", &self.after), + ("before", &self.before), + ] { + if let Some(value) = value { + query.append_pair(key, value); + } + } + query.finish() + } + + /// Narrows more than `after`, `before` and `limit` do, so it covers fewer than all flows. + pub fn narrows(&self) -> bool { + let given = |v: &Option| v.as_deref().is_some_and(|v| !v.trim().is_empty()); + given(&self.q) + || given(&self.host) + || given(&self.method) + || given(&self.status) + || self.under.is_some() + || self.app.is_some() + } + + /// The SQL condition for every field but `after`, `before` and `limit`, and its parameters. + fn condition(&self) -> (String, Vec) { + let mut sql = String::from("1"); + let mut params: Vec = Vec::new(); + for term in self.q.iter().flat_map(|q| q.split_whitespace()) { + let term = term.to_lowercase(); + sql.push_str( + " AND (instr(lower(url), ?) > 0 OR lower(method) = ? \ + OR instr(lower(content_type), ?) > 0", + ); + params.extend([ + term.clone().into(), + term.clone().into(), + term.clone().into(), + ]); + if let Some(status) = status_condition(&term, &mut params) { + sql.push_str(" OR "); + sql.push_str(&status); + } + sql.push(')'); + } + if let Some(host) = self.host.as_deref().filter(|h| !h.is_empty()) { + sql.push_str(&format!(" AND instr(lower({HOST_SQL}), ?) > 0")); + params.push(host.to_lowercase().into()); + } + if let Some(under) = &self.under { + // The node itself, or anything below it, by the sidebar's rules. + let mut any = Vec::new(); + for node in under { + let node = normalize_node(node); + any.push("(sidebar_path(url) = ? OR substr(sidebar_path(url), 1, ?) = ?)"); + let below = format!("{node}/"); + params.extend([ + node.into(), + (below.chars().count() as i64).into(), + below.into(), + ]); + } + let any = if any.is_empty() { "0".to_string() } else { any.join(" OR ") }; + sql.push_str(&format!(" AND ({any})")); + } + if let Some(apps) = &self.app { + let marks = vec!["?"; apps.len()].join(", "); + sql.push_str(&format!(" AND coalesce(client_bundle_id, client_path) IN ({marks})")); + params.extend(apps.iter().map(|a| a.clone().into())); + } + if let Some(method) = self.method.as_deref().filter(|m| !m.is_empty()) { + sql.push_str(" AND lower(method) = ?"); + params.push(method.to_lowercase().into()); + } + match self.status.as_deref().map(str::trim) { + None | Some("") => {} + Some(s) if s.eq_ignore_ascii_case("error") => sql.push_str(" AND error IS NOT NULL"), + // A whole status or a class, not the start of one as a search term may be. + Some(s) if s.len() == 3 => match status_condition(&s.to_ascii_lowercase(), &mut params) + { + Some(status) => { + sql.push_str(" AND "); + sql.push_str(&status); + } + None => sql.push_str(" AND 0"), + }, + Some(_) => sql.push_str(" AND 0"), + } + (sql, params) + } +} + +/// The host and port of `url`, in SQL: what follows `://` up to the first `/`, `?` or `#`. +const HOST_SQL: &str = "(SELECT CASE WHEN instr(r, '/') > 0 THEN substr(r, 1, instr(r, '/') - 1) \ + ELSE r END FROM (SELECT replace(replace(CASE WHEN instr(url, '://') > 0 \ + THEN substr(url, instr(url, '://') + 3) ELSE url END, '?', '/'), '#', '/') AS r))"; + +/// Where the app's sidebar places a URL: its host, lowercased and without its scheme's default +/// port, then its path's non-empty segments, joined by `/`, as in `api.stripe.com/v1/charges`. +/// A URL without a scheme is a host of its own. `sidebarPath` in the app's `lib/search.ts` +/// follows the same rules, and the sidebar's nodes are built from it. +pub fn sidebar_path(url: &str) -> String { + let Some((scheme, rest)) = url.split_once("://") else { + return url.to_string(); + }; + let end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let (authority, rest) = rest.split_at(end); + let mut host = authority.to_ascii_lowercase(); + let default_port = match scheme.to_ascii_lowercase().as_str() { + "http" | "ws" => Some(":80"), + "https" | "wss" => Some(":443"), + _ => None, + }; + if let Some(port) = default_port + && host.ends_with(port) + { + host.truncate(host.len() - port.len()); + } + let path = rest.split(['?', '#']).next().unwrap_or_default(); + let mut key = host; + for segment in path.split('/').filter(|s| !s.is_empty()) { + key.push('/'); + key.push_str(segment); + } + key +} + +/// A sidebar node as `sidebar_path` writes it, its host lowercased. +fn normalize_node(node: &str) -> String { + match node.split_once('/') { + Some((host, path)) => format!("{}/{path}", host.to_ascii_lowercase()), + None => node.to_ascii_lowercase(), + } +} + +/// The SQL condition for a status term: `404` exactly, `40` as the start of one, or a class +/// such as `4xx`. None when the term is not one. +fn status_condition(term: &str, params: &mut Vec) -> Option { + if let Some(class) = term.strip_suffix("xx") + && let Ok(class @ 1..=9) = class.parse::() + { + params.push(class.into()); + return Some("status / 100 = ?".into()); + } + if term.is_empty() || term.len() > 3 || !term.chars().all(|c| c.is_ascii_digit()) { + return None; + } + if term.len() == 3 { + params.push(term.parse::().ok()?.into()); + return Some("status = ?".into()); + } + params.push(format!("{term}%").into()); + Some("CAST(status AS TEXT) LIKE ?".into()) +} + +/// The flows `filter` covers, newest first: by start, then by id. Reads the table in that order +/// through its index and stops at the limit, so a page of a common match costs the same however +/// many flows there are. +pub fn list(db: &ProxyQueryManager, filter: &FlowFilter) -> Result, String> { + db.with_conn(|c| list_in(c.conn().resolve(), filter)) +} + +/// [`list`] and the count of every flow, read from one snapshot of the database, so the count +/// takes in exactly the flows written before the page was read. With a filter that narrows, +/// also the count of the flows it covers, whatever the limit. +pub fn page( + db: &ProxyQueryManager, + filter: &FlowFilter, +) -> Result<(Vec, usize, Option), String> { + db.with_conn(|c| { + let tx = c.conn().resolve().unchecked_transaction().map_err(|e| e.to_string())?; + let flows = list_in(&tx, filter)?; + let total = count_in(&tx)?; + let matching = if filter.narrows() { Some(count_matching(&tx, filter)?) } else { None }; + tx.commit().map_err(|e| e.to_string())?; + Ok((flows, total, matching)) + }) +} + +fn list_in(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result, String> { + let (condition, mut params) = filter.condition(); + let mut sql = format!("SELECT * FROM flows WHERE {condition}"); + if let Some(id) = &filter.after { + // By rowid, which only grows as flows are written. + let rowid: i64 = conn + .query_row("SELECT rowid FROM flows WHERE id = ?", [id], |r| r.get(0)) + .map_err(|_| format!("No flow {id}"))?; + sql.push_str(" AND rowid > ?"); + params.push(rowid.into()); + } + if let Some(id) = &filter.before { + let started_at: Value = conn + .query_row("SELECT started_at FROM flows WHERE id = ?", [id], |r| r.get(0)) + .map_err(|_| format!("No flow {id}"))?; + sql.push_str(" AND (started_at, id) < (?, ?)"); + params.push(started_at); + params.push(id.clone().into()); + } + sql.push_str(" ORDER BY started_at DESC, id DESC"); + if let Some(limit) = filter.limit { + sql.push_str(" LIMIT ?"); + params.push((limit.min(i64::MAX as usize) as i64).into()); + } + let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; + let rows = stmt + .query_map(rusqlite::params_from_iter(params), Flow::from_row) + .map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string()) +} + +fn count_matching(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result { + let (condition, params) = filter.condition(); + conn.query_row( + &format!("SELECT COUNT(*) FROM flows WHERE {condition}"), + rusqlite::params_from_iter(params), + |r| r.get::<_, i64>(0), + ) + .map(|n| n as usize) + .map_err(|e| e.to_string()) +} + +/// Deletes every flow and their bodies, in a transaction in each database. The flows' deletion +/// commits only after their bodies' has, so when clearing the bodies fails the flows stay, to be +/// cleared again. Returns how many there were. +pub fn clear(db: &ProxyQueryManager, blobs: &BlobManager) -> Result { + db.with_tx(|c| { + let conn = c.conn().resolve(); + let mut stmt = conn.prepare("SELECT id FROM flows")?; + let ids = stmt.query_map([], |r| r.get(0))?.collect::, _>>()?; + conn.execute("DELETE FROM flows", [])?; + // Only the deleted flows' bodies, so a flow still in flight keeps what it has written. + blobs + .with_tx(|b| { + for id in &ids { + b.delete_chunks(&Flow::body_id(id, FlowBody::Request))?; + b.delete_chunks(&Flow::body_id(id, FlowBody::Response))?; + } + Ok::<_, yaak_models::error::Error>(()) + }) + .map_err(|e| yaak_database::Error::Database(format!("flow bodies: {e}")))?; + Ok::<_, yaak_database::Error>(ids.len()) + }) + .map_err(|e| format!("Failed to clear flows: {e}")) +} + +pub fn find(db: &ProxyQueryManager, flow_id: &str) -> Option { + db.with_conn(|c| c.find_optional::(FlowIden::Id, flow_id)) +} + +pub fn get(db: &ProxyQueryManager, flow_id: &str) -> Result { + find(db, flow_id).ok_or_else(|| format!("No flow {flow_id}")) +} + +pub fn count(db: &ProxyQueryManager) -> Result { + db.with_conn(|c| count_in(c.conn().resolve())) +} + +fn count_in(conn: &rusqlite::Connection) -> Result { + conn.query_row("SELECT COUNT(*) FROM flows", [], |r| r.get::<_, i64>(0)) + .map(|n| n as usize) + .map_err(|e| e.to_string()) +} + +/// A body in JSON: text when it is UTF-8, base64 when it is not. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BodyPreview { + pub data: String, + pub encoding: BodyEncoding, + /// Bytes in `data` once decoded. + pub size: usize, + /// The body is longer than `data`: cut at the capture limit or at the preview cap. + pub truncated: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum BodyEncoding { + Utf8, + Base64, +} + +impl BodyPreview { + pub fn new(data: &[u8], truncated: bool) -> Self { + let (data, encoding) = match std::str::from_utf8(data) { + Ok(text) => (text.to_string(), BodyEncoding::Utf8), + // A cut can split a character; that alone does not make the body binary. + Err(e) if truncated && e.error_len().is_none() => { + (String::from_utf8_lossy(&data[..e.valid_up_to()]).into_owned(), BodyEncoding::Utf8) + } + Err(_) => (BASE64.encode(data), BodyEncoding::Base64), + }; + let size = match encoding { + BodyEncoding::Utf8 => data.len(), + BodyEncoding::Base64 => BASE64.decode(&data).map(|d| d.len()).unwrap_or_default(), + }; + Self { data, encoding, size, truncated } + } +} + +/// One flow with its bodies, each decoded and cut at `body_cap` bytes. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FlowDetail { + pub flow: Flow, + pub request_body: Option, + pub response_body: Option, +} + +pub async fn detail( + db: &ProxyQueryManager, + blobs: &BlobManager, + flow_id: &str, + body_cap: usize, +) -> Result { + let flow = get(db, flow_id)?; + let request_body = preview(blobs, &flow, FlowBody::Request, body_cap).await?; + let response_body = preview(blobs, &flow, FlowBody::Response, body_cap).await?; + Ok(FlowDetail { flow, request_body, response_body }) +} + +async fn preview( + blobs: &BlobManager, + flow: &Flow, + which: FlowBody, + cap: usize, +) -> Result, String> { + let body = bodies::read_body(blobs, flow, which, cap).await?; + if body.data.is_empty() && !body.truncated { + return Ok(None); + } + Ok(Some(BodyPreview::new(&body.data, body.truncated))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn flow(url: &str, method: &str, status: Option, error: Option<&str>) -> Flow { + Flow { + url: url.into(), + method: method.into(), + status, + error: error.map(String::from), + ..Default::default() + } + } + + /// A database of `flows`, started a second apart in the order given, and a function that + /// lists the urls a filter covers, newest first. + fn db_of(flows: Vec) -> (tempfile::TempDir, ProxyQueryManager) { + use yaak_database::UpdateSource; + let dir = tempfile::tempdir().unwrap(); + let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap(); + for (i, flow) in flows.into_iter().enumerate() { + let started_at = chrono::DateTime::from_timestamp(i as i64, 0).unwrap().naive_utc(); + let flow = Flow { id: format!("fl_{i:03}"), started_at, ..flow }; + db.with_tx(|tx| tx.upsert(&flow, &UpdateSource::Background)).unwrap(); + } + (dir, db) + } + + fn urls(db: &ProxyQueryManager, filter: FlowFilter) -> Vec { + list(db, &filter).unwrap().into_iter().map(|f| f.url).collect() + } + + #[test] + fn filters() { + let (_dir, db) = db_of(vec![ + flow("https://api.stripe.com:8443/v1/charges?x=1", "POST", Some(200), None), + flow("http://example.com/missing", "GET", Some(404), None), + flow("https://down.example.com/", "GET", None, Some("refused")), + ]); + let by = |filter: FlowFilter| urls(&db, filter).len(); + assert_eq!(by(FlowFilter::default()), 3); + assert_eq!(by(FlowFilter { host: Some("STRIPE".into()), ..Default::default() }), 1); + assert_eq!(by(FlowFilter { host: Some("example.com".into()), ..Default::default() }), 2); + assert_eq!(by(FlowFilter { host: Some("charges".into()), ..Default::default() }), 0); + assert_eq!(by(FlowFilter { host: Some("8443".into()), ..Default::default() }), 1); + assert_eq!(by(FlowFilter { method: Some("get".into()), ..Default::default() }), 2); + assert_eq!(by(FlowFilter { status: Some("404".into()), ..Default::default() }), 1); + assert_eq!(by(FlowFilter { status: Some("2xx".into()), ..Default::default() }), 1); + assert_eq!(by(FlowFilter { status: Some("5XX".into()), ..Default::default() }), 0); + assert_eq!(by(FlowFilter { status: Some("40".into()), ..Default::default() }), 0); + assert_eq!(by(FlowFilter { status: Some("error".into()), ..Default::default() }), 1); + } + + #[test] + fn search_matches_url_method_status_and_content_type() { + let json = |url: &str, method: &str, status| Flow { + content_type: Some("application/json; charset=utf-8".into()), + ..flow(url, method, Some(status), None) + }; + let (_dir, db) = db_of(vec![ + json("https://api.stripe.com/v1/charges?customer=cus_9", "POST", 200), + json("https://api.github.com/repos/yaak", "GET", 404), + Flow { + content_type: Some("image/png".into()), + ..flow("https://images.ctfassets.net/logo.png", "GET", Some(200), None) + }, + flow("http://localhost:3000/health", "HEAD", Some(503), None), + ]); + let search = |q: &str| { + let filter = FlowFilter { q: Some(q.into()), ..Default::default() }; + let mut found = urls(&db, filter); + found.sort(); + found + }; + let stripe = "https://api.stripe.com/v1/charges?customer=cus_9"; + let github = "https://api.github.com/repos/yaak"; + let logo = "https://images.ctfassets.net/logo.png"; + let health = "http://localhost:3000/health"; + // The host, the path and the query, any case. + assert_eq!(search("STRIPE"), vec![stripe]); + assert_eq!(search("/repos/"), vec![github]); + assert_eq!(search("cus_9"), vec![stripe]); + // The method, whole. + assert_eq!(search("head"), vec![health]); + assert_eq!(search("ead"), Vec::<&str>::new()); + // A status, its start, or its class. + assert_eq!(search("404"), vec![github]); + assert_eq!(search("50"), vec![health]); + assert_eq!(search("2xx"), vec![stripe, logo]); + // The content type. + assert_eq!(search("png"), vec![logo]); + assert_eq!(search("json"), vec![github, stripe]); + // Every term must match, each anywhere. + assert_eq!(search("json get"), vec![github]); + assert_eq!(search(" json 2xx "), vec![stripe]); + assert_eq!(search("json 5xx"), Vec::<&str>::new()); + // `%` and `_` are plain characters, not patterns. + assert_eq!(search("%"), Vec::<&str>::new()); + assert_eq!(search("cus_"), vec![stripe]); + assert_eq!(search(""), vec![health, github, stripe, logo]); + } + + #[test] + fn under_matches_a_host_or_a_path_as_the_sidebar_does() { + let (_dir, db) = db_of(vec![ + flow("https://api.stripe.com/v1/charges", "GET", Some(200), None), + flow("https://api.stripe.com/v1/charges/ch_1?expand=x", "GET", Some(200), None), + flow("https://api.stripe.com/v1/chargesx", "GET", Some(200), None), + flow("https://api.stripe.com:8443/v1/charges", "GET", Some(200), None), + flow("http://api.stripe.com", "GET", Some(200), None), + flow("https://api.stripe.community/", "GET", Some(200), None), + flow("https://example.com/v1//users", "GET", Some(200), None), + flow("http://x.com:80/a", "GET", Some(200), None), + flow("https://x.com:443/a", "GET", Some(200), None), + ]); + let under = |nodes: &[&str]| { + let nodes = nodes.iter().map(|n| n.to_string()).collect(); + urls(&db, FlowFilter { under: Some(nodes), ..Default::default() }).len() + }; + // A host on another port is a node of its own, as in the sidebar. + assert_eq!(under(&["api.stripe.com"]), 4); + assert_eq!(under(&["api.stripe.com:8443"]), 1); + assert_eq!(under(&["api.stripe.com/v1/charges"]), 2); + // Empty segments and default ports are left out, as the sidebar leaves them. + assert_eq!(under(&["example.com/v1/users"]), 1); + assert_eq!(under(&["x.com/a"]), 2); + assert_eq!(under(&["api.stripe.com/v1/charges", "example.com"]), 3); + assert_eq!(under(&[]), 0); + // The sidebar lowercases hosts; paths keep their case. + assert_eq!(under(&["API.stripe.com/v1/charges"]), 2); + assert_eq!(under(&["api.stripe.com/V1/charges"]), 0); + } + + /// The cases `lib/search.test.ts` checks `sidebarPath` against, so the two agree. + #[test] + fn sidebar_path_rules() { + for (url, path) in [ + ("https://example.com/v1//users", "example.com/v1/users"), + ("http://x.com:80/a", "x.com/a"), + ("https://x.com:443/a?q=1#f", "x.com/a"), + ("http://x.com:443/a", "x.com:443/a"), + ("https://API.Stripe.com:8443/", "api.stripe.com:8443"), + ("https://api.stripe.com", "api.stripe.com"), + ("https://api.stripe.com?x=/y", "api.stripe.com"), + ("http://[::1]:80/a/B", "[::1]/a/B"), + ("/api", "/api"), + ] { + assert_eq!(sidebar_path(url), path, "{url}"); + } + } + + #[test] + fn app_matches_a_bundle_id_or_a_path() { + let from = |bundle_id: Option<&str>, path: &str| Flow { + client_bundle_id: bundle_id.map(String::from), + client_path: Some(path.into()), + ..flow("https://example.com/", "GET", Some(200), None) + }; + let (_dir, db) = db_of(vec![ + from(Some("com.google.Chrome"), "/Applications/Google Chrome.app"), + from(Some("com.google.Chrome"), "/Applications/Google Chrome.app"), + from(None, "/usr/bin/curl"), + flow("https://example.com/", "GET", Some(200), None), + ]); + let app = |apps: &[&str]| { + let apps = apps.iter().map(|a| a.to_string()).collect(); + urls(&db, FlowFilter { app: Some(apps), ..Default::default() }).len() + }; + assert_eq!(app(&["com.google.Chrome"]), 2); + assert_eq!(app(&["/usr/bin/curl"]), 1); + assert_eq!(app(&["/Applications/Google Chrome.app"]), 0); + assert_eq!(app(&["com.google.Chrome", "/usr/bin/curl"]), 3); + } + + #[test] + fn a_search_pages_back_and_counts_what_it_matches() { + // Every third flow is a POST, newest last. + let (_dir, db) = db_of( + (0..30) + .map(|i| { + let method = if i % 3 == 0 { "POST" } else { "GET" }; + flow(&format!("https://api.example.com/{i}"), method, Some(200), None) + }) + .collect(), + ); + let search = |before: Option| FlowFilter { + q: Some("post".into()), + before, + limit: Some(4), + ..Default::default() + }; + let (first, total, matching) = page(&db, &search(None)).unwrap(); + let ids = |flows: &[Flow]| flows.iter().map(|f| f.id.clone()).collect::>(); + assert_eq!(ids(&first), vec!["fl_027", "fl_024", "fl_021", "fl_018"]); + assert_eq!((total, matching), (30, Some(10))); + let (second, _, _) = page(&db, &search(Some("fl_018".into()))).unwrap(); + assert_eq!(ids(&second), vec!["fl_015", "fl_012", "fl_009", "fl_006"]); + let (last, _, _) = page(&db, &search(Some("fl_006".into()))).unwrap(); + assert_eq!(ids(&last), vec!["fl_003", "fl_000"]); + // Without a narrowing filter there is nothing more to count. + let (_, _, matching) = + page(&db, &FlowFilter { limit: Some(1), ..Default::default() }).unwrap(); + assert_eq!(matching, None); + } + + #[test] + fn a_search_reads_the_newest_page_through_the_index() { + let (_dir, db) = db_of(vec![]); + let plan: Vec = db.with_conn(|c| { + let conn = c.conn().resolve(); + let (condition, params) = + FlowFilter { q: Some("stripe".into()), ..Default::default() }.condition(); + let sql = format!( + "EXPLAIN QUERY PLAN SELECT * FROM flows WHERE {condition} \ + ORDER BY started_at DESC, id DESC LIMIT 500" + ); + let mut stmt = conn.prepare(&sql).unwrap(); + stmt.query_map(rusqlite::params_from_iter(params), |r| r.get::<_, String>(3)) + .unwrap() + .map(|r| r.unwrap()) + .collect() + }); + assert!(plan.iter().any(|p| p.contains("idx_flows_started_at_id")), "{plan:?}"); + assert!(!plan.iter().any(|p| p.contains("TEMP B-TREE")), "{plan:?}"); + } + + #[test] + fn after_follows_flows_as_they_are_recorded() { + use yaak_database::UpdateSource; + let dir = tempfile::tempdir().unwrap(); + let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap(); + let at = |s| chrono::DateTime::from_timestamp(s, 0).unwrap().naive_utc(); + // A slow flow that started first but ended, and so was written, after a quick one. + for (id, started) in [("fl_quick", 20), ("fl_slow", 10), ("fl_next", 30)] { + let flow = Flow { id: id.into(), started_at: at(started), ..Default::default() }; + db.with_tx(|tx| tx.upsert(&flow, &UpdateSource::Background)).unwrap(); + } + + let after = |id: &str| { + let filter = FlowFilter { after: Some(id.into()), ..Default::default() }; + list(&db, &filter).unwrap().into_iter().map(|f| f.id).collect::>() + }; + assert_eq!(after("fl_quick"), vec!["fl_next", "fl_slow"]); + assert_eq!(after("fl_next"), Vec::::new()); + let missing = FlowFilter { after: Some("fl_gone".into()), ..Default::default() }; + assert_eq!(list(&db, &missing).unwrap_err(), "No flow fl_gone"); + } + + #[test] + fn before_pages_back_through_older_flows() { + use yaak_database::UpdateSource; + let dir = tempfile::tempdir().unwrap(); + let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap(); + let at = |s| chrono::DateTime::from_timestamp(s, 0).unwrap().naive_utc(); + // Two started together, which their ids order. + for (id, started) in [ + ("fl_a", 10), + ("fl_b", 20), + ("fl_c", 20), + ("fl_d", 30), + ("fl_e", 40), + ] { + let flow = Flow { + id: id.into(), + started_at: at(started), + status: Some(if id == "fl_c" { 404 } else { 200 }), + ..Default::default() + }; + db.with_tx(|tx| tx.upsert(&flow, &UpdateSource::Background)).unwrap(); + } + + let page = |filter: FlowFilter| { + list(&db, &filter).unwrap().into_iter().map(|f| f.id).collect::>() + }; + let newest = FlowFilter { limit: Some(2), ..Default::default() }; + assert_eq!(page(newest), vec!["fl_e", "fl_d"]); + let older = + FlowFilter { before: Some("fl_d".into()), limit: Some(2), ..Default::default() }; + assert_eq!(page(older), vec!["fl_c", "fl_b"]); + let oldest = + FlowFilter { before: Some("fl_b".into()), limit: Some(2), ..Default::default() }; + assert_eq!(page(oldest), vec!["fl_a"]); + // The limit counts flows the filter keeps. + let ok = FlowFilter { + before: Some("fl_e".into()), + status: Some("2xx".into()), + limit: Some(2), + ..Default::default() + }; + assert_eq!(page(ok), vec!["fl_d", "fl_b"]); + let missing = FlowFilter { before: Some("fl_gone".into()), ..Default::default() }; + assert_eq!(list(&db, &missing).unwrap_err(), "No flow fl_gone"); + } + + #[test] + fn clear_deletes_flows_and_their_bodies() { + use yaak_database::UpdateSource; + use yaak_models::blob_manager::BodyChunkWriter; + let dir = tempfile::tempdir().unwrap(); + let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap(); + let blobs = yaak_models::init_blob_store(dir.path().join("blobs.sqlite")).unwrap(); + let write_body = |id: &str, which| { + let mut writer = BodyChunkWriter::new(blobs.clone(), Flow::body_id(id, which)); + writer.write(b"body").unwrap(); + writer.finish().unwrap(); + }; + for id in ["fl_a", "fl_b"] { + write_body(id, FlowBody::Request); + write_body(id, FlowBody::Response); + let flow = Flow { id: id.into(), ..Default::default() }; + db.with_tx(|tx| tx.upsert(&flow, &UpdateSource::Background)).unwrap(); + } + // A flow still in flight has written a body but not its row. + write_body("fl_live", FlowBody::Request); + + // Bodies that fail to clear keep their flows, to clear again. + let read_only = crate::db::open_blobs_read_only(&dir.path().join("blobs.sqlite")).unwrap(); + assert!(clear(&db, &read_only).is_err()); + assert_eq!(count(&db).unwrap(), 2); + + assert_eq!(clear(&db, &blobs).unwrap(), 2); + assert_eq!(count(&db).unwrap(), 0); + let body_ids = blobs.connect().list_body_ids().unwrap(); + assert_eq!(body_ids, vec![Flow::body_id("fl_live", FlowBody::Request)]); + assert_eq!(clear(&db, &blobs).unwrap(), 0); + } + + #[test] + fn query_round_trip() { + let filter = FlowFilter { + q: Some("post json".into()), + host: Some("api.stripe.com".into()), + under: Some(vec!["api.stripe.com/v1".into(), "example.com".into()]), + app: Some(vec!["com.google.Chrome".into()]), + status: Some("4xx".into()), + limit: Some(20), + ..Default::default() + }; + assert_eq!( + filter.to_query(), + "q=post+json&host=api.stripe.com&under=api.stripe.com%2Fv1&under=example.com\ + &app=com.google.Chrome&status=4xx&limit=20" + ); + assert_eq!(FlowFilter::from_query(&filter.to_query()).unwrap(), filter); + assert_eq!(FlowFilter::from_query("").unwrap(), FlowFilter::default()); + assert!(FlowFilter::from_query("limit=lots").is_err()); + } + + #[test] + fn previews() { + let text = BodyPreview::new("héllo".as_bytes(), false); + assert_eq!( + (text.data.as_str(), text.encoding, text.size), + ("héllo", BodyEncoding::Utf8, 6) + ); + + // Cut inside the "é". + let cut = BodyPreview::new(&"héllo".as_bytes()[..2], true); + assert_eq!( + (cut.data.as_str(), cut.encoding, cut.truncated), + ("h", BodyEncoding::Utf8, true) + ); + + let binary = BodyPreview::new(&[0xff, 0x00, 0xfe], false); + assert_eq!( + (binary.data.as_str(), binary.encoding, binary.size), + ("/wD+", BodyEncoding::Base64, 3) + ); + } +} diff --git a/crates-proxy/yaak-proxy-lib/src/har.rs b/crates-proxy/yaak-proxy-lib/src/har.rs new file mode 100644 index 000000000..fc08df008 --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/src/har.rs @@ -0,0 +1,422 @@ +//! HAR 1.2 export (http://www.softwareishard.com/blog/har-12-spec/). Bodies are decoded with +//! their Content-Encoding; one that is not UTF-8 is written in base64 with `encoding: "base64"`. +//! Fields the spec leaves to the exporter start with an underscore: `_truncated` on a body cut +//! at the capture limit and `_error` on a flow that failed. + +use crate::bodies::{self, DecodedBody}; +use crate::flows::{BodyEncoding, BodyPreview}; +use crate::models::{Flow, FlowBody, ProxyHeader, find_header}; +use serde::{Deserialize, Serialize}; +use yaak_models::blob_manager::BlobManager; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Har { + pub log: HarLog, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HarLog { + pub version: String, + pub creator: HarCreator, + pub entries: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HarCreator { + pub name: String, + pub version: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HarEntry { + pub started_date_time: String, + pub time: i64, + pub request: HarRequest, + pub response: HarResponse, + pub cache: serde_json::Map, + pub timings: HarTimings, + #[serde(rename = "serverIPAddress", skip_serializing_if = "Option::is_none")] + pub server_ip_address: Option, + #[serde(rename = "_error", skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HarRequest { + pub method: String, + pub url: String, + pub http_version: String, + pub cookies: Vec, + pub headers: Vec, + pub query_string: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub post_data: Option, + pub headers_size: i64, + pub body_size: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HarResponse { + pub status: i32, + pub status_text: String, + pub http_version: String, + pub cookies: Vec, + pub headers: Vec, + pub content: HarContent, + #[serde(rename = "redirectURL")] + pub redirect_url: String, + pub headers_size: i64, + pub body_size: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HarNameValue { + pub name: String, + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HarPostData { + pub mime_type: String, + pub text: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub encoding: Option, + #[serde( + rename = "_truncated", + skip_serializing_if = "std::ops::Not::not", + default + )] + pub truncated: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HarContent { + pub size: i64, + pub mime_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub encoding: Option, + #[serde( + rename = "_truncated", + skip_serializing_if = "std::ops::Not::not", + default + )] + pub truncated: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HarTimings { + pub send: i64, + pub wait: i64, + pub receive: i64, +} + +/// A HAR of `flows`, reading each body from `blobs` and keeping at most `limit` bytes of it. +pub async fn export(blobs: &BlobManager, flows: &[Flow], limit: usize) -> Result { + let mut entries = Vec::with_capacity(flows.len()); + for flow in flows { + let request = bodies::read_body(blobs, flow, FlowBody::Request, limit).await?; + let response = bodies::read_body(blobs, flow, FlowBody::Response, limit).await?; + entries.push(entry(flow, &request, &response)); + } + Ok(har(entries)) +} + +pub fn har(entries: Vec) -> Har { + Har { + log: HarLog { + version: "1.2".into(), + creator: HarCreator { + name: "Yaak Proxy".into(), + version: env!("CARGO_PKG_VERSION").into(), + }, + entries, + }, + } +} + +pub fn entry(flow: &Flow, request_body: &DecodedBody, response_body: &DecodedBody) -> HarEntry { + let elapsed = flow.elapsed_ms.map_or(0, i64::from).max(0); + let wait = flow.elapsed_headers_ms.map_or(elapsed, i64::from).clamp(0, elapsed); + let responded = flow.status.is_some(); + + HarEntry { + started_date_time: flow.started_at.and_utc().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(), + time: elapsed, + request: HarRequest { + method: flow.method.clone(), + url: flow.url.clone(), + http_version: flow.client_http_version.clone(), + cookies: Vec::new(), + headers: name_values(&flow.request_headers), + query_string: query_string(&flow.url), + post_data: post_data(flow, request_body), + headers_size: -1, + body_size: flow.request_size, + }, + response: HarResponse { + status: flow.status.unwrap_or(0), + status_text: flow + .status + .and_then(|s| hyper::StatusCode::from_u16(u16::try_from(s).ok()?).ok()) + .and_then(|s| s.canonical_reason()) + .unwrap_or_default() + .to_string(), + http_version: flow.upstream_http_version.clone().unwrap_or_default(), + cookies: Vec::new(), + headers: name_values(&flow.response_headers), + content: content(flow, response_body), + redirect_url: find_header(&flow.response_headers, "location") + .unwrap_or_default() + .to_string(), + headers_size: -1, + body_size: if responded { flow.response_size } else { -1 }, + }, + cache: Default::default(), + timings: HarTimings { send: 0, wait, receive: elapsed - wait }, + server_ip_address: flow.remote_addr.as_deref().map(ip_of), + error: flow + .error + .clone() + .or_else(|| response_body.decode_error.clone()) + .or_else(|| request_body.decode_error.clone()), + } +} + +fn name_values(headers: &[ProxyHeader]) -> Vec { + headers.iter().map(|h| HarNameValue { name: h.name.clone(), value: h.value.clone() }).collect() +} + +fn query_string(url: &str) -> Vec { + let Some((_, query)) = url.split_once('?') else { + return Vec::new(); + }; + let query = query.split('#').next().unwrap_or_default(); + url::form_urlencoded::parse(query.as_bytes()) + .map(|(name, value)| HarNameValue { name: name.into_owned(), value: value.into_owned() }) + .collect() +} + +fn post_data(flow: &Flow, body: &DecodedBody) -> Option { + if body.data.is_empty() && !body.truncated { + return None; + } + let preview = BodyPreview::new(&body.data, body.truncated); + Some(HarPostData { + mime_type: find_header(&flow.request_headers, "content-type").unwrap_or_default().into(), + encoding: base64_marker(preview.encoding), + text: preview.data, + truncated: body.truncated, + }) +} + +fn content(flow: &Flow, body: &DecodedBody) -> HarContent { + let mime_type = find_header(&flow.response_headers, "content-type").unwrap_or_default().into(); + if body.data.is_empty() && !body.truncated { + return HarContent { size: 0, mime_type, text: None, encoding: None, truncated: false }; + } + let preview = BodyPreview::new(&body.data, body.truncated); + HarContent { + size: body.data.len() as i64, + mime_type, + encoding: base64_marker(preview.encoding), + text: Some(preview.data), + truncated: body.truncated, + } +} + +fn base64_marker(encoding: BodyEncoding) -> Option { + (encoding == BodyEncoding::Base64).then(|| "base64".to_string()) +} + +/// The address without its port: `127.0.0.1:443` to `127.0.0.1`, `[::1]:443` to `::1`. +fn ip_of(addr: &str) -> String { + match addr.parse::() { + Ok(a) => a.ip().to_string(), + Err(_) => addr.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + const GOLDEN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/export.har"); + + fn header(name: &str, value: &str) -> ProxyHeader { + ProxyHeader { name: name.into(), value: value.into() } + } + + fn body(data: &[u8], truncated: bool) -> DecodedBody { + DecodedBody { data: data.to_vec(), stored_size: data.len(), truncated, decode_error: None } + } + + fn at(second: u32) -> chrono::NaiveDateTime { + NaiveDate::from_ymd_opt(2026, 10, 26) + .unwrap() + .and_hms_milli_opt(9, 30, second, 250) + .unwrap() + } + + fn flows() -> Vec<(Flow, DecodedBody, DecodedBody)> { + let json = Flow { + id: "fl_json".into(), + started_at: at(1), + method: "GET".into(), + url: + "https://api.github.com/repos/mountain-loop/yaak/issues?state=open&labels=bug%20fix" + .into(), + client_http_version: "HTTP/1.1".into(), + upstream_http_version: Some("HTTP/2".into()), + remote_addr: Some("140.82.112.6:443".into()), + status: Some(200), + content_type: Some("application/json; charset=utf-8".into()), + request_headers: vec![ + header("Host", "api.github.com"), + header("Accept", "application/vnd.github+json"), + ], + response_headers: vec![ + header("content-type", "application/json; charset=utf-8"), + header("content-encoding", "gzip"), + ], + response_size: 41, + elapsed_headers_ms: Some(120), + elapsed_ms: Some(134), + ..Default::default() + }; + let form = Flow { + id: "fl_form".into(), + started_at: at(2), + method: "POST".into(), + url: "http://localhost:3000/login".into(), + client_http_version: "HTTP/1.1".into(), + upstream_http_version: Some("HTTP/1.1".into()), + remote_addr: Some("[::1]:3000".into()), + status: Some(302), + request_headers: vec![header("Content-Type", "application/x-www-form-urlencoded")], + response_headers: vec![header("Location", "/dashboard")], + request_size: 26, + elapsed_headers_ms: Some(8), + elapsed_ms: Some(8), + ..Default::default() + }; + let image = Flow { + id: "fl_image".into(), + started_at: at(3), + method: "GET".into(), + url: "https://images.example.com/logo.png".into(), + client_http_version: "HTTP/1.1".into(), + upstream_http_version: Some("HTTP/2".into()), + status: Some(200), + response_headers: vec![header("Content-Type", "image/png")], + response_size: 8, + elapsed_headers_ms: Some(30), + elapsed_ms: Some(31), + ..Default::default() + }; + let cut = Flow { + id: "fl_cut".into(), + started_at: at(4), + method: "GET".into(), + url: "https://cdn.example.com/big.txt".into(), + client_http_version: "HTTP/1.1".into(), + upstream_http_version: Some("HTTP/1.1".into()), + status: Some(200), + response_headers: vec![header("Content-Type", "text/plain")], + response_size: 20_000_000, + response_truncated: true, + elapsed_headers_ms: Some(10), + elapsed_ms: Some(900), + error: Some("connection reset by peer".into()), + ..Default::default() + }; + let failed = Flow { + id: "fl_failed".into(), + started_at: at(5), + method: "GET".into(), + url: "https://down.example.com/".into(), + client_http_version: "HTTP/1.1".into(), + elapsed_ms: Some(3), + error: Some("Connection refused (os error 61)".into()), + ..Default::default() + }; + vec![ + (json, body(b"", false), body(br#"[{"number":761,"title":"MCP"}]"#, false)), + (form, body(b"user=greg&password=hunter2", false), body(b"", false)), + ( + image, + body(b"", false), + body(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a], false), + ), + (cut, body(b"", false), body(b"0123456789", true)), + (failed, body(b"", false), body(b"", false)), + ] + } + + #[tokio::test] + async fn export_keeps_a_body_that_fails_to_decode() { + use std::io::Write; + use yaak_models::blob_manager::BodyChunkWriter; + + // Text that does not compress to nothing, gzipped, with the stream cut in half. + let mut seed = 7u32; + let text: Vec = (0..8000) + .map(|_| { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345); + b'a' + (seed >> 16) as u8 % 26 + }) + .collect(); + let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + gz.write_all(&text).unwrap(); + let gz = gz.finish().unwrap(); + + let (_db, blobs, _rx) = yaak_models::init_in_memory().unwrap(); + let mut writer = + BodyChunkWriter::new(blobs.clone(), Flow::body_id("fl_bad", FlowBody::Response)); + writer.write(&gz[..gz.len() / 2]).unwrap(); + writer.finish().unwrap(); + let flow = Flow { + id: "fl_bad".into(), + method: "GET".into(), + url: "https://cdn.example.com/a.txt".into(), + status: Some(200), + response_headers: vec![ + header("Content-Type", "text/plain"), + header("Content-Encoding", "gzip"), + ], + ..Default::default() + }; + + let har = export(&blobs, &[flow], 1024 * 1024).await.unwrap(); + let entry = &har.log.entries[0]; + let text_kept = entry.response.content.text.as_deref().unwrap(); + assert!(!text_kept.is_empty() && text.starts_with(text_kept.as_bytes())); + assert!(entry.response.content.truncated); + assert!( + entry.error.as_deref().unwrap().starts_with("Failed to decode"), + "{:?}", + entry.error + ); + } + + #[test] + fn golden_file() { + let entries = flows().iter().map(|(f, req, res)| entry(f, req, res)).collect(); + let mut har = har(entries); + har.log.creator.version = "test".into(); + let actual = serde_json::to_string_pretty(&har).unwrap() + "\n"; + + if std::env::var_os("UPDATE_GOLDEN").is_some() { + std::fs::write(GOLDEN, &actual).unwrap(); + } + let expected = std::fs::read_to_string(GOLDEN).unwrap(); + assert_eq!(actual, expected, "rerun with UPDATE_GOLDEN=1 to accept a change"); + } +} diff --git a/crates-proxy/yaak-proxy-lib/src/helper.rs b/crates-proxy/yaak-proxy-lib/src/helper.rs new file mode 100644 index 000000000..3ef2988df --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/src/helper.rs @@ -0,0 +1,222 @@ +//! The privileged helper from the app's side: its registration with launchd through +//! `SMAppService`, and requests over its socket. Only the bundled, signed app can register it +//! or be answered by it; `tauri dev` and the CLI see it as unavailable. + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_rpc.ts")] +pub enum HelperState { + /// Not running from a bundle with the helper inside: a dev build, the CLI, or not macOS. + Unavailable, + NotRegistered, + /// Registered, and waiting for the user to allow it in System Settings. + RequiresApproval, + Enabled, + /// The app has the helper's plist, but `SMAppService` reports it not found. + NotFound, +} + +#[cfg(target_os = "macos")] +mod imp { + use super::HelperState; + use objc2::rc::Retained; + use objc2_foundation::NSString; + use objc2_service_management::{SMAppService, SMAppServiceStatus}; + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::UnixStream; + use std::path::PathBuf; + use std::time::{Duration, Instant}; + use yaak_proxy_helper::protocol::{Request, Response, Status}; + + /// How long a just-approved daemon gets to open its socket. + const CONNECT_WAIT: Duration = Duration::from_secs(3); + + /// Long enough for `networksetup` on every service. + const IO_TIMEOUT: Duration = Duration::from_secs(30); + + /// The helper's plist inside this app, when it runs from a bundle that has one, so + /// `SMAppService` can find it. + fn plist_path() -> Option { + let exe = std::env::current_exe().ok()?; + let contents = exe.parent()?.parent()?; + let path = contents.join("Library/LaunchDaemons").join(yaak_proxy_helper::PLIST_NAME); + path.is_file().then_some(path) + } + + fn service() -> Option> { + plist_path()?; + let plist = NSString::from_str(yaak_proxy_helper::PLIST_NAME); + // SAFETY: a class method taking a valid NSString. + Some(unsafe { SMAppService::daemonServiceWithPlistName(&plist) }) + } + + fn raw_status(service: &SMAppService) -> SMAppServiceStatus { + // SAFETY: reads the service's registration status. + unsafe { service.status() } + } + + fn state_of(service: &SMAppService) -> HelperState { + match raw_status(service) { + SMAppServiceStatus::Enabled => HelperState::Enabled, + SMAppServiceStatus::RequiresApproval => HelperState::RequiresApproval, + SMAppServiceStatus::NotRegistered => HelperState::NotRegistered, + _ => HelperState::NotFound, + } + } + + fn status_name(status: SMAppServiceStatus) -> &'static str { + match status { + SMAppServiceStatus::NotRegistered => "notRegistered", + SMAppServiceStatus::Enabled => "enabled", + SMAppServiceStatus::RequiresApproval => "requiresApproval", + SMAppServiceStatus::NotFound => "notFound", + _ => "unknown", + } + } + + pub fn state() -> HelperState { + let Some(plist) = plist_path() else { + log::debug!("No helper plist in this app, so the helper is unavailable"); + return HelperState::Unavailable; + }; + let Some(service) = service() else { + return HelperState::Unavailable; + }; + let status = raw_status(&service); + log::debug!( + "SMAppService status {} ({}) for {}", + status.0, + status_name(status), + plist.display() + ); + state_of(&service) + } + + /// What `SMAppService` says about the helper, for `--helper-status`. + pub fn diagnose() -> String { + let exe = std::env::current_exe() + .map(|p| p.display().to_string()) + .unwrap_or_else(|e| format!("unknown ({e})")); + let mut lines = vec![format!("executable: {exe}")]; + match plist_path() { + Some(plist) => lines.push(format!("plist: {}", plist.display())), + None => lines.push(format!( + "plist: none at Contents/Library/LaunchDaemons/{}", + yaak_proxy_helper::PLIST_NAME + )), + } + if let Some(service) = service() { + let status = raw_status(&service); + lines.push(format!("SMAppService status: {} ({})", status.0, status_name(status))); + } + lines.push(format!("state: {:?}", state())); + lines.join("\n") + } + + /// Registers the daemon and, while it awaits approval, opens the System Settings pane + /// that allows it. + pub fn register() -> Result { + let service = service().ok_or_else(unavailable)?; + // SAFETY: registers the plist bundled with this app. + let registered = unsafe { service.registerAndReturnError() }; + let state = state_of(&service); + match state { + // Registering asks for approval by failing with "Operation not permitted". + HelperState::RequiresApproval => { + // SAFETY: opens System Settings; no arguments. + unsafe { SMAppService::openSystemSettingsLoginItems() }; + Ok(state) + } + HelperState::Enabled => Ok(state), + _ => Err(registered + .err() + .map(|e| format!("Failed to install the helper: {}", e.localizedDescription())) + .unwrap_or_else(|| "Failed to install the helper".to_string())), + } + } + + /// Removes the daemon, once it has put the system proxy back. + pub fn unregister() -> Result { + let service = service().ok_or_else(unavailable)?; + // A helper that has not put the settings back stays registered, so launchd restarts it + // and it can try again; removed, nothing could restore them. + if state_of(&service) == HelperState::Enabled { + request(&Request::Unregister).map_err(|e| { + format!( + "The helper could not restore your proxy settings, so it was kept: {e}. Try \ + again in a moment." + ) + })?; + } + // SAFETY: unregisters the plist bundled with this app. + unsafe { service.unregisterAndReturnError() } + .map_err(|e| format!("Failed to remove the helper: {}", e.localizedDescription()))?; + Ok(state_of(&service)) + } + + fn unavailable() -> String { + "The helper is only available in the installed app, not a dev build".to_string() + } + + /// Sends one request and reads its response. + pub fn request(request: &Request) -> Result { + let start = Instant::now(); + let stream = loop { + match UnixStream::connect(yaak_proxy_helper::SOCKET_PATH) { + Ok(stream) => break stream, + Err(_) if start.elapsed() < CONNECT_WAIT => { + std::thread::sleep(Duration::from_millis(200)); + } + Err(e) => return Err(format!("The helper is not running: {e}")), + } + }; + exchange(stream, request) + } + + fn exchange(stream: UnixStream, request: &Request) -> Result { + let _ = stream.set_read_timeout(Some(IO_TIMEOUT)); + let _ = stream.set_write_timeout(Some(IO_TIMEOUT)); + let mut line = serde_json::to_string(request).map_err(|e| e.to_string())?; + line.push('\n'); + (&stream).write_all(line.as_bytes()).map_err(|e| format!("The helper hung up: {e}"))?; + let mut reply = String::new(); + BufReader::new(&stream) + .read_line(&mut reply) + .map_err(|e| format!("The helper did not answer: {e}"))?; + let response: Response = serde_json::from_str(&reply) + .map_err(|e| format!("The helper's answer was not understood: {e}"))?; + response.into_result() + } + + /// Points the system proxy at `port`, or puts back what was there, owned by this process. + pub fn set(enabled: bool, port: u16) -> Result<(), String> { + let owner_pid = std::process::id() as i32; + request(&Request::Set { enabled, port, owner_pid }).map(|_| ()) + } +} + +#[cfg(target_os = "macos")] +pub use imp::{diagnose, register, set, state, unregister}; + +#[cfg(not(target_os = "macos"))] +pub fn state() -> HelperState { + HelperState::Unavailable +} + +#[cfg(not(target_os = "macos"))] +pub fn diagnose() -> String { + "The helper is only available on macOS".to_string() +} + +#[cfg(not(target_os = "macos"))] +pub fn register() -> Result { + Err("The helper is only available on macOS".to_string()) +} + +#[cfg(not(target_os = "macos"))] +pub fn unregister() -> Result { + Ok(HelperState::Unavailable) +} diff --git a/crates-proxy/yaak-proxy-lib/src/lib.rs b/crates-proxy/yaak-proxy-lib/src/lib.rs index f8cace44d..f27f7277c 100644 --- a/crates-proxy/yaak-proxy-lib/src/lib.rs +++ b/crates-proxy/yaak-proxy-lib/src/lib.rs @@ -1,18 +1,47 @@ pub mod actions; +pub mod attribution; +pub mod bodies; +pub mod ca; +pub mod client; +pub mod control; +pub mod convert; pub mod db; +pub mod flows; +pub mod har; +pub mod helper; +pub mod macos; pub mod models; +pub mod save; +pub mod settings; use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction}; +use crate::attribution::Attribution; +use crate::ca::CaStatus; +use crate::client::ControlClient; +use crate::control::{ControlFile, ControlInfo, ControlServer, Owner}; +use crate::convert::DroppedHeader; use crate::db::ProxyQueryManager; -use crate::models::{HttpExchange, ModelPayload, ProxyHeader}; -use log::warn; +use crate::helper::HelperState; +use crate::macos::{SystemProxyBackend, SystemProxyStatus}; +use crate::models::{Flow, FlowBody, FlowIden, ModelPayload, ProxyHeader, find_header}; +use crate::save::{ClientDb, SaveFlowRequest, SaveFlowResponse, WorkspaceSummary}; +use crate::settings::{HttpVersion, ProxySettings}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use chrono::{DateTime, Utc}; +use log::{debug, warn}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::path::Path; -use std::sync::{Arc, Mutex}; +use std::io::Write; +use std::net::{SocketAddr, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, SystemTime}; use ts_rs::TS; -use yaak_database::{ModelChangeEvent, UpdateSource}; -use yaak_proxy::{CapturedRequest, ProxyEvent, ProxyHandle, RequestState}; +use yaak_database::{ModelChangeEvent, UpdateSource, UpsertModelInfo}; +use yaak_models::blob_manager::{BlobManager, BodyChunkWriter}; +use yaak_proxy::{CaStore, ProxyConfig, ProxyEvent, ProxyHandle}; use yaak_rpc::{RpcError, RpcEventEmitter, define_rpc}; // -- Context -- @@ -21,13 +50,595 @@ use yaak_rpc::{RpcError, RpcEventEmitter, define_rpc}; #[derive(Clone)] pub struct ProxyCtx { handle: Arc>>, + data_dir: PathBuf, + owner: Owner, + client_data_dir: PathBuf, + control: Arc>>, + /// The control API this app serves while it owns the engine. Not the CLI daemon's, which + /// holds its own. + control_server: Arc>>, + /// Held through each system proxy change, password dialog included, so they apply in turn. + system_proxy_turn: Arc>, + /// The port the engine last ran on, which the system proxy may still point at. + last_port: Arc>>, + /// Flows are stored. While paused, traffic still passes through, untouched and unrecorded. + recording: Arc>, + /// This Mac's web traffic is meant to point at the running engine. + system_proxy_on: Arc, + /// Why the engine last failed to start, until it starts. + start_error: Arc>>, pub db: ProxyQueryManager, + pub blobs: BlobManager, pub events: RpcEventEmitter, } impl ProxyCtx { - pub fn new(db_path: &Path, events: RpcEventEmitter) -> Self { - Self { handle: Arc::new(Mutex::new(None)), db: ProxyQueryManager::new(db_path), events } + /// Opens `proxy.db` and `blobs.sqlite` in `data_dir`, the proxy app's data directory. + pub fn new(data_dir: &Path, events: RpcEventEmitter) -> Result { + let db = ProxyQueryManager::new(&data_dir.join("proxy.db")) + .map_err(|e| format!("Failed to open proxy database: {e}"))?; + let blobs = yaak_models::init_blob_store(data_dir.join("blobs.sqlite")) + .map_err(|e| format!("Failed to open proxy blob store: {e}"))?; + Ok(Self { + handle: Arc::new(Mutex::new(None)), + data_dir: data_dir.to_path_buf(), + owner: Owner::App, + client_data_dir: save::client_data_dir(), + control: Arc::new(Mutex::new(None)), + control_server: Arc::new(Mutex::new(None)), + system_proxy_turn: Arc::new(Mutex::new(())), + last_port: Arc::new(Mutex::new(None)), + recording: Arc::new(Mutex::new(RecordingLog::default())), + system_proxy_on: Arc::new(AtomicBool::new(false)), + start_error: Arc::new(Mutex::new(None)), + db, + blobs, + events, + }) + } + + /// The process this runs in, which decides how the system proxy gets admin rights. + pub fn with_owner(mut self, owner: Owner) -> Self { + self.owner = owner; + self + } + + /// Yaak's data directory, where flows are saved as requests. + pub fn with_client_data_dir(mut self, dir: impl Into) -> Self { + self.client_data_dir = dir.into(); + self + } + + pub fn data_dir(&self) -> &Path { + &self.data_dir + } + + pub fn owner(&self) -> Owner { + self.owner + } + + pub fn settings_path(&self) -> PathBuf { + self.data_dir.join("settings.json") + } + + pub fn settings(&self) -> Result { + ProxySettings::load(&self.settings_path()) + } + + pub fn capture_body_limit(&self) -> Result { + Ok(self.settings()?.capture_body_limit) + } + + /// Starts the engine with the current `settings.json`. Does nothing if it is running. + pub fn start(&self) -> Result<(), String> { + self.start_on(None) + } + + /// Starts the engine on `port`, or the one in `settings.json`. Does nothing if it is + /// running. Points the system proxy at it when the settings ask for that. Refused while + /// another process owns the engine, so there are never two. A failure is kept until the + /// next start, for the app to show. + pub fn start_on(&self, port: Option) -> Result<(), String> { + let started = self.start_engine(port); + if let Ok(mut start_error) = self.start_error.lock() { + *start_error = started.as_ref().err().cloned(); + } + if started.is_err() { + self.events + .emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Stopped }); + } + started + } + + /// Why the engine last failed to start, if it has not started since. + pub fn start_error(&self) -> Option { + self.start_error.lock().ok()?.clone() + } + + fn start_engine(&self, port: Option) -> Result<(), String> { + if let Some(owner) = control::live_owner(&self.data_dir) + && owner.pid != std::process::id() + { + return Err(control::already_owned(&owner)); + } + let mut handle = self.handle.lock().map_err(|_| "lock poisoned".to_string())?; + if handle.is_some() { + return Ok(()); + } + + let settings = self.settings()?; + let ca = CaStore::load_or_create(&self.ca_dir()) + .map_err(|e| format!("Failed to load proxy CA: {e}"))?; + let mut config = ProxyConfig::new(port.unwrap_or(settings.port), Arc::new(ca)); + config.capture_body_limit = settings.capture_body_limit; + config.http2 = settings.http_version == HttpVersion::Auto; + // Filled once the engine has bound, since the lookup matches sockets by its port. + let attribution_slot: Arc> = Arc::new(OnceLock::new()); + let slot = attribution_slot.clone(); + config.on_accept = Some(Arc::new(move |connection, client_addr| { + // A client that is answered quickly may exit soon after, so it is found before + // anything is read from it: a few milliseconds once per connection at most. + if let Some(attribution) = slot.get() { + attribution.begin(connection, client_addr); + attribution.get(connection, ACCEPT_ATTRIBUTION_WAIT); + } + })); + let mut proxy_handle = yaak_proxy::start_proxy(config)?; + let attribution = Attribution::new(proxy_handle.port); + let _ = attribution_slot.set(attribution.clone()); + + if let Some(event_rx) = proxy_handle.take_event_rx() { + let sink = FlowSink { + db: self.db.clone(), + blobs: self.blobs.clone(), + events: self.events.clone(), + attribution, + recording: self.recording.clone(), + }; + std::thread::spawn(move || run_event_loop(event_rx, sink)); + } + + let bound = proxy_handle.port; + *handle = Some(proxy_handle); + drop(handle); + self.set_control_proxy_port(Some(bound)); + if let Ok(mut last_port) = self.last_port.lock() { + *last_port = Some(bound); + } + self.system_proxy_on.store(settings.system_proxy_at_launch, Ordering::SeqCst); + if settings.system_proxy_at_launch { + // The password dialog can sit open for a while; the engine is up regardless. + let ctx = self.clone(); + std::thread::spawn(move || { + if let Err(e) = ctx.change_system_proxy(true, bound, true) { + warn!("Failed to turn on the system proxy: {e}"); + ctx.system_proxy_on.store(false, Ordering::SeqCst); + } + // So the app reads the system proxy again once the dialog is answered. + ctx.events + .emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Running }); + }); + } + self.events.emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Running }); + Ok(()) + } + + /// Stops the engine and turns off a system proxy that points at it. + pub fn stop(&self) -> Result<(), String> { + self.system_proxy_on.store(false, Ordering::SeqCst); + let port = self.take_handle()?; + if let Some(port) = port { + let ctx = self.clone(); + std::thread::spawn(move || { + if let Err(e) = ctx.change_system_proxy(false, port, true) { + warn!("Failed to turn off the system proxy: {e}"); + } + }); + } + self.events.emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Stopped }); + Ok(()) + } + + /// [`Self::stop`] for a process about to exit: waits for the system proxy to be off, also + /// when a `stop` just before is still turning it off. + pub fn shutdown(&self) { + let running = self.take_handle().ok().flatten(); + let last = self.last_port.lock().ok().and_then(|p| *p); + if let Some(port) = running.or(last) + && let Err(e) = self.change_system_proxy(false, port, false) + { + warn!("Failed to turn off the system proxy: {e}"); + } + } + + /// Stops the engine, returning the port it was on. + fn take_handle(&self) -> Result, String> { + let mut handle = self.handle.lock().map_err(|_| "lock poisoned".to_string())?; + // Dropping the handle stops the listener and joins its thread. + let port = handle.take().map(|h| h.port); + drop(handle); + self.set_control_proxy_port(None); + Ok(port) + } + + /// The port the engine is bound to, while it runs. + pub fn port(&self) -> Option { + self.handle.lock().ok()?.as_ref().map(|h| h.port) + } + + pub fn ca_dir(&self) -> PathBuf { + self.data_dir.join("ca") + } + + pub(crate) fn set_control_file(&self, file: Option) { + if let Ok(mut control) = self.control.lock() { + *control = file; + } + } + + /// This process's `control.json`, while it serves the control API. + pub fn control_info(&self) -> Option { + self.control.lock().ok()?.as_ref().map(|c| c.info.clone()) + } + + /// Claims the engine for this app and serves the control API, unless it already does. + /// Fails while another live owner, the CLI daemon, has it. Resets a system proxy a crashed + /// owner left pointing at its port. + pub fn serve_control_api(&self) -> Result<(), String> { + let mut slot = self.control_server.lock().map_err(|_| "lock poisoned".to_string())?; + if slot.is_some() { + return Ok(()); + } + // Read first: a crashed owner's file names the port its system proxy may still point at. + let previous = control::read(&self.data_dir); + *slot = Some(ControlServer::start(self.clone())?); + drop(slot); + + let mut ports: Vec = previous.and_then(|p| p.proxy_port).into_iter().collect(); + ports.extend(self.settings().ok().map(|s| s.port)); + let ctx = self.clone(); + std::thread::spawn(move || { + if let Err(e) = ctx.reset_stale_system_proxy(&ports) { + warn!("Failed to reset a stale system proxy: {e}"); + } + }); + Ok(()) + } + + /// Stops serving the control API and gives up the claim on the engine. + pub fn stop_control_api(&self) { + let server = self.control_server.lock().ok().and_then(|mut s| s.take()); + drop(server); + } + + /// Another live owner of the engine, such as the CLI daemon. + pub fn other_owner(&self) -> Option { + let ours = self.control_info().map(|c| c.token); + control::live_owner(&self.data_dir).filter(|o| Some(&o.token) != ours.as_ref()) + } + + /// Stops the engine in the process that owns it, waits for that process to give it up, + /// then claims it here and starts it. Fails, leaving things as they are, when the owner + /// does not let go. + pub async fn take_over(&self) -> Result<(), String> { + if let Some(owner) = self.other_owner() { + // Stopped through the control API, another app halts its engine but keeps the claim, + // so only the CLI daemon, which exits on it, can be taken over. + if owner.owner == Owner::App { + return Err(ANOTHER_APP_OWNS.to_string()); + } + ControlClient::new(owner.clone()).stop().await?; + let ctx = self.clone(); + let released = tokio::task::spawn_blocking(move || { + let deadline = std::time::Instant::now() + TAKE_OVER_WAIT; + while std::time::Instant::now() < deadline { + if ctx.other_owner().is_none() { + return true; + } + std::thread::sleep(Duration::from_millis(100)); + } + false + }) + .await + .map_err(|e| e.to_string())?; + if !released { + return Err(format!( + "The proxy in {} (pid {}) stopped but did not let go of it", + control::owner_name(owner.owner), + owner.pid + )); + } + } + let ctx = self.clone(); + tokio::task::spawn_blocking(move || { + ctx.serve_control_api()?; + ctx.start() + }) + .await + .map_err(|e| e.to_string())? + } + + fn set_control_proxy_port(&self, port: Option) { + if let Ok(mut control) = self.control.lock() + && let Some(control) = control.as_mut() + { + control.set_proxy_port(port); + } + } + + /// The helper once it is enabled; until then, and while the password dialog still has the + /// user's settings to put back, the dialog. The CLI daemon always uses `sudo`. + pub fn system_proxy_backend(&self) -> SystemProxyBackend { + self.backend_for(self.helper_state()) + } + + fn backend_for(&self, helper: HelperState) -> SystemProxyBackend { + match self.owner { + Owner::Cli => SystemProxyBackend::Sudo, + // What the dialog saved only it can restore, as the helper never saw it: settings + // for routing it turned on, and settings parked for a service that was missing, + // which its next capture uses if the service is back. + Owner::App if helper == HelperState::Enabled && !self.snapshot_path().exists() => { + SystemProxyBackend::Helper + } + Owner::App => SystemProxyBackend::AdminPrompt, + } + } + + /// The helper's registration, as far as this process can use it. + pub fn helper_state(&self) -> HelperState { + match self.owner { + Owner::App => helper::state(), + Owner::Cli => HelperState::Unavailable, + } + } + + /// Where the password dialog and `sudo` keep the settings they replaced. + fn snapshot_path(&self) -> PathBuf { + self.data_dir.join(macos::SNAPSHOT_FILE) + } + + /// Judged against the running engine's port, or the configured one while it is stopped. + /// Blocks. + pub fn system_proxy_status(&self) -> Result { + let port = self.port().unwrap_or(self.settings()?.port); + let helper = self.helper_state(); + macos::status(port, self.backend_for(helper), helper) + } + + /// Registers the helper, opening System Settings for the user to allow it. Blocks. + pub fn register_helper(&self) -> Result { + helper::register()?; + self.system_proxy_status() + } + + /// Removes the helper, which first puts back the settings it replaced, so the system proxy + /// is off afterwards. Blocks. + pub fn unregister_helper(&self) -> Result { + let turn = self.system_proxy_turn.lock().map_err(|_| "lock poisoned".to_string())?; + let restores = self.system_proxy_backend() == SystemProxyBackend::Helper; + helper::unregister()?; + if restores { + self.system_proxy_on.store(false, Ordering::SeqCst); + } + drop(turn); + self.system_proxy_status() + } + + /// Points this Mac's web traffic at the running engine, or stops, at once, through the + /// password dialog. Not saved: the `systemProxyAtLaunch` setting decides whether it turns + /// on as the engine starts. Blocks. + pub fn set_system_proxy(&self, enabled: bool) -> Result { + let Some(port) = self.port() else { + if enabled { + return Err("The proxy is not running, so there is nothing to route to".into()); + } + // Clears one left pointing at the configured port; asks for nothing otherwise. + self.change_system_proxy(false, self.settings()?.port, false)?; + return self.system_proxy_status(); + }; + let previous = self.system_proxy_on.swap(enabled, Ordering::SeqCst); + if let Err(e) = self.change_system_proxy(enabled, port, enabled) { + self.system_proxy_on.store(previous, Ordering::SeqCst); + return Err(e); + } + self.system_proxy_status() + } + + /// Whether the system proxy turns on as the engine starts, saved in `settings.json`. + pub fn set_system_proxy_at_launch(&self, enabled: bool) -> Result<(), String> { + let mut settings = self.settings()?; + settings.system_proxy_at_launch = enabled; + settings.save(&self.settings_path()) + } + + /// The HTTP versions offered to clients, saved in `settings.json` and applied to the running + /// engine's next connections. + pub fn set_http_version(&self, version: HttpVersion) -> Result<(), String> { + let mut settings = self.settings()?; + settings.http_version = version; + settings.save(&self.settings_path())?; + if let Ok(handle) = self.handle.lock() + && let Some(handle) = handle.as_ref() + { + handle.set_http2(version == HttpVersion::Auto); + } + Ok(()) + } + + /// Intercepts again the hosts passed through after clients rejected the CA, as they no + /// longer will once it is trusted. + pub fn forget_passthrough(&self) { + if let Ok(handle) = self.handle.lock() + && let Some(handle) = handle.as_ref() + { + handle.forget_passthrough(); + } + } + + /// Flows are being stored. + pub fn recording(&self) -> bool { + self.recording.lock().map(|r| r.is_on()).unwrap_or(true) + } + + /// Resumes or pauses storing flows. Paused, traffic still passes through the engine, but no + /// new flow is stored; one already under way is finished. + pub fn set_recording(&self, recording: bool) { + let changed = self.recording.lock().map(|mut r| r.set(recording)).unwrap_or(false); + if changed { + let state = + if self.port().is_some() { ProxyState::Running } else { ProxyState::Stopped }; + self.events.emit("proxy_state_changed", &ProxyStatePayload { state }); + } + } + + /// Points the system proxy at `port`, or turns it off there, once the changes before it + /// are done. With `follow_engine`, the change is dropped if the engine has moved on by its + /// turn: an enable once the engine is off `port` or the setting is off, a disable once the + /// engine is back on `port`. A start and a stop in quick succession then end in the state + /// the engine is in, whichever password dialog is answered first. + fn change_system_proxy( + &self, + enabled: bool, + port: u16, + follow_engine: bool, + ) -> Result<(), String> { + let _turn = self.system_proxy_turn.lock().map_err(|_| "lock poisoned".to_string())?; + if follow_engine { + let on_port = self.port() == Some(port); + let wanted = self.system_proxy_on.load(Ordering::SeqCst); + let current = if enabled { on_port && wanted } else { !on_port }; + if !current { + return Ok(()); + } + } + macos::set(enabled, port, self.system_proxy_backend(), &self.snapshot_path()) + } + + /// Turns off a system proxy left pointing at one of `ports` by an owner that crashed, so + /// the Mac is not left offline, putting back the settings it replaced when they were + /// saved. Leaves it alone when something is serving on the port. Blocks. + pub fn reset_stale_system_proxy(&self, ports: &[u16]) -> Result<(), String> { + let mut ports = ports.to_vec(); + ports.extend(macos::saved_port(&self.snapshot_path())); + ports.sort_unstable(); + ports.dedup(); + for port in ports { + if port == 0 { + continue; + } + // Checked in its turn, so an engine started on the port meanwhile keeps its proxy. + let _turn = self.system_proxy_turn.lock().map_err(|_| "lock poisoned".to_string())?; + if Some(port) == self.port() || is_listening(port) { + continue; + } + // Covers disabled services too, and asks for nothing when none point at the port. + debug!("Turning off any stale system proxy on port {port}"); + macos::set(false, port, self.system_proxy_backend(), &self.snapshot_path())?; + } + Ok(()) + } + + /// Yaak has run on this machine, so there is a database to save into. + pub fn client_exists(&self) -> bool { + ClientDb::exists(&self.client_data_dir) + } + + /// Yaak's workspaces and their folders, to save a flow into. + pub fn workspaces(&self) -> Result, String> { + ClientDb::open(&self.client_data_dir)?.workspaces() + } + + /// Saves a flow as a request in Yaak and records the request on the flow. + pub async fn save_flow( + &self, + flow_id: &str, + req: &SaveFlowRequest, + ) -> Result { + let flow = flows::get(&self.db, flow_id)?; + let limit = self.capture_body_limit()?; + let body = bodies::read_body(&self.blobs, &flow, FlowBody::Request, limit).await?; + let saved = ClientDb::open(&self.client_data_dir)?.save(&flow, &body, req)?; + + let flow = Flow { saved_request_id: Some(saved.request.id.clone()), ..flow }; + self.db + .with_tx(|tx| { + // Cleared while saving: recording it would bring the flow back without bodies. + if tx.find_optional::(FlowIden::Id, flow_id).is_none() { + return Ok(()); + } + let (flow, created) = tx.upsert(&flow, &UpdateSource::Background)?; + // Sent before the commit, so it cannot reach the app after a clear that follows. + self.events.emit( + "model_write", + &ModelPayload { model: flow, change: ModelChangeEvent::Upsert { created } }, + ); + Ok::<_, yaak_database::Error>(()) + }) + .map_err(|e| format!("Failed to record the saved request on the flow: {e}"))?; + Ok(saved) + } + + /// Deletes every flow and its bodies, and tells the app so its list empties. Returns how + /// many there were. + pub fn clear_flows(&self) -> Result { + let cleared = flows::clear(&self.db, &self.blobs)?; + // After the commit, so the app reloads what is left. Flows written before the clear sent + // their events before it began. + self.events.emit("flows_cleared", &FlowsClearedPayload {}); + Ok(cleared) + } +} + +/// Something accepts connections on `127.0.0.1:`. +fn is_listening(port: u16) -> bool { + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() +} + +// -- Recording -- + +/// Changes to recording kept, so a request whose events are read late is still judged by when +/// it started. +const RECORDING_CHANGES: usize = 64; + +/// Whether flows are stored, and when that changed. +struct RecordingLog { + /// Oldest first, starting from recording since the beginning. + changes: std::collections::VecDeque<(SystemTime, bool)>, +} + +impl Default for RecordingLog { + fn default() -> Self { + Self { changes: [(SystemTime::UNIX_EPOCH, true)].into() } + } +} + +impl RecordingLog { + fn is_on(&self) -> bool { + self.changes.back().is_none_or(|(_, on)| *on) + } + + /// Returns whether it changed. + fn set(&mut self, on: bool) -> bool { + if self.is_on() == on { + return false; + } + self.changes.push_back((SystemTime::now(), on)); + while self.changes.len() > RECORDING_CHANGES { + self.changes.pop_front(); + } + true + } + + /// Whether flows were stored at `time`. + fn was_on_at(&self, time: SystemTime) -> bool { + self.changes + .iter() + .rev() + .find(|(at, _)| *at <= time) + .or(self.changes.front()) + .is_none_or(|(_, on)| *on) } } @@ -59,73 +670,571 @@ pub struct ListActionsResponse { pub actions: Vec<(ActionInvocation, ActionMetadata)>, } -#[derive(Deserialize, TS)] -#[ts(export, export_to = "gen_rpc.ts")] -pub struct ListModelsRequest {} - #[derive(Serialize, TS)] #[ts(export, export_to = "gen_rpc.ts")] #[serde(rename_all = "camelCase")] -pub struct ListModelsResponse { - pub http_exchanges: Vec, +pub struct ListFlowsResponse { + /// Newest first. + pub flows: Vec, + /// Flows in the database, whatever the filter. + #[ts(type = "number")] + pub total: usize, + /// Flows the filter covers, whatever the limit, when it narrows to fewer than all of them. + #[ts(type = "number | null")] + pub matching: Option, } +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct ClearFlowsRequest {} + +#[derive(Serialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct FlowsClearedPayload {} + #[derive(Deserialize, TS)] #[ts(export, export_to = "gen_rpc.ts")] pub struct GetProxyStateRequest {} #[derive(Serialize, TS)] #[ts(export, export_to = "gen_rpc.ts")] +#[serde(rename_all = "camelCase")] pub struct GetProxyStateResponse { pub state: ProxyState, + /// The port the engine is bound to, while it runs. + pub port: Option, + /// The port the engine starts on. + pub configured_port: u16, + /// Flows are stored. Paused, traffic still passes through, unrecorded. + pub recording: bool, + /// The system proxy turns on as the engine starts. + pub system_proxy_at_launch: bool, + /// The HTTP versions offered to clients. + pub http_version: HttpVersion, + /// Why the engine last failed to start, until it starts. + pub start_error: Option, + /// Another process that owns the engine, such as the CLI daemon. While one does, this app + /// cannot start its own, and new flows reach it only through the database. + pub other_owner: Option, } +#[derive(Serialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +#[serde(rename_all = "camelCase")] +pub struct OtherOwner { + pub owner: Owner, + pub pid: u32, + pub started_at: DateTime, + /// The engine's port while it runs. + pub proxy_port: Option, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct TakeOverRequest {} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct SetRecordingRequest { + pub enabled: bool, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct SetSystemProxyAtLaunchRequest { + pub enabled: bool, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +#[serde(rename_all = "camelCase")] +pub struct SetHttpVersionRequest { + pub http_version: HttpVersion, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct AppIconRequest { + /// A flow's `clientPath`. + pub path: String, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct ListWorkspacesRequest {} + +#[derive(Serialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +#[serde(rename_all = "camelCase")] +pub struct ListWorkspacesResponse { + /// Yaak has run on this machine. Without it there is nothing to save into. + pub yaak_found: bool, + pub workspaces: Vec, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +#[serde(rename_all = "camelCase")] +pub struct PreviewSaveRequest { + pub flow_id: String, +} + +/// What saving a flow as a Yaak request writes, for the save dialog. +#[derive(Serialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +#[serde(rename_all = "camelCase")] +pub struct SavePreview { + pub method: String, + pub url: String, + /// The URL as saved, its query split out into parameters, which Yaak shows for an unnamed + /// request. + pub request_url: String, + /// The saved body type, which Yaak shows for an unnamed GraphQL request with no URL. + pub body_type: Option, + /// Headers Yaak sets itself or that only meant something to the proxy. + pub dropped_headers: Vec, + /// Headers saved as captured, Authorization among them. + #[ts(type = "number")] + pub kept_headers: usize, + /// The body as it is saved: "JSON", "form URL-encoded with 7 fields". None without a body. + pub body: Option, + /// What does not carry over exactly, one sentence each. + pub warnings: Vec, + /// An Authorization header is saved in plain text. + pub authorization: bool, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +#[serde(rename_all = "camelCase")] +pub struct SaveFlowToYaakRequest { + pub flow_id: String, + pub workspace_id: String, + #[ts(optional)] + pub folder_id: Option, + /// None or blank leaves the request unnamed, and Yaak shows its URL. + #[ts(optional)] + pub name: Option, +} + +#[derive(Serialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +#[serde(rename_all = "camelCase")] +pub struct SavedFlow { + pub request_id: String, + pub name: String, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +#[serde(rename_all = "camelCase")] +pub struct ExportHarRequest { + /// The file to write. + pub path: String, + /// These flows, or else every flow the filter covers. + #[ts(optional)] + pub ids: Option>, + #[ts(optional)] + pub filter: Option, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +#[serde(rename_all = "camelCase")] +pub struct GetFlowBodyRequest { + pub flow_id: String, + pub which: FlowBody, +} + +#[derive(Serialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +#[serde(rename_all = "camelCase")] +pub struct GetFlowBodyResponse { + /// The body decoded with its Content-Encoding, at most the capture limit of it, in base64. + /// When it fails to decode, the bytes as they were on the wire instead. + pub data: String, + /// The body is incomplete: cut on the wire, or decoded past the capture limit. + pub truncated: bool, + /// Bytes stored for the body, as they were on the wire. + #[ts(type = "number")] + pub stored_size: usize, + /// Why the body failed to decode, in which case `data` is as it was on the wire. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub decode_error: Option, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +#[serde(rename_all = "camelCase")] +pub struct SaveFlowBodyRequest { + pub flow_id: String, + pub which: FlowBody, + /// The file to write. + pub path: String, +} + +#[derive(Serialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +#[serde(rename_all = "camelCase")] +pub struct SaveFlowBodyResponse { + /// Bytes written. + #[ts(type = "number")] + pub size: usize, + /// What was written is incomplete, as `GetFlowBodyResponse.truncated` is. + pub truncated: bool, + /// The body failed to decode, so the file has it as it was on the wire. + pub raw: bool, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct FormatBodyRequest { + pub text: String, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +#[serde(rename_all = "camelCase")] +pub struct FilterBodyRequest { + pub flow_id: String, + pub which: FlowBody, + /// JSONPath, or XPath for an XML or HTML body. + pub expression: String, + /// The language the viewer detected from the body. Without it, the Content-Type decides. + #[ts(optional)] + pub language: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +#[serde(rename_all = "lowercase")] +pub enum FilterLanguage { + JsonPath, + XPath, +} + +#[derive(Serialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct FilterBodyResponse { + pub content: String, + pub error: Option, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct CaStatusRequest {} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct CaTrustRequest {} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct SystemProxyStatusRequest {} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct SystemProxySetRequest { + pub enabled: bool, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct HelperRegisterRequest {} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct HelperUnregisterRequest {} + // -- Handlers -- fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result { match invocation { ActionInvocation::Global { action } => match action { - GlobalAction::ProxyStart => { - let mut handle = - ctx.handle.lock().map_err(|_| RpcError { message: "lock poisoned".into() })?; - - if handle.is_some() { - return Ok(true); // already running - } - - let mut proxy_handle = - yaak_proxy::start_proxy(9090).map_err(|e| RpcError { message: e })?; - - if let Some(event_rx) = proxy_handle.take_event_rx() { - let db = ctx.db.clone(); - let events = ctx.events.clone(); - std::thread::spawn(move || run_event_loop(event_rx, db, events)); - } - - *handle = Some(proxy_handle); - ctx.events - .emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Running }); - Ok(true) - } - GlobalAction::ProxyStop => { - let mut handle = - ctx.handle.lock().map_err(|_| RpcError { message: "lock poisoned".into() })?; - handle.take(); - ctx.events - .emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Stopped }); - Ok(true) + GlobalAction::RecordingToggle => ctx.set_recording(!ctx.recording()), + GlobalAction::FlowsClear => { + ctx.clear_flows().map_err(|message| RpcError { message })?; } }, } + Ok(true) } -fn get_proxy_state( - ctx: &ProxyCtx, +async fn get_proxy_state( + ctx: ProxyCtx, _req: GetProxyStateRequest, ) -> Result { - let handle = ctx.handle.lock().map_err(|_| RpcError { message: "lock poisoned".into() })?; - let state = if handle.is_some() { ProxyState::Running } else { ProxyState::Stopped }; - Ok(GetProxyStateResponse { state }) + // The owner check takes a file lock and a connection, so off the async threads. + blocking(move || { + let port = ctx.port(); + let state = if port.is_some() { ProxyState::Running } else { ProxyState::Stopped }; + let settings = ctx.settings()?; + let other_owner = ctx.other_owner().map(|o| OtherOwner { + owner: o.owner, + pid: o.pid, + started_at: o.started_at, + proxy_port: o.proxy_port, + }); + Ok(GetProxyStateResponse { + state, + port, + configured_port: settings.port, + recording: ctx.recording(), + system_proxy_at_launch: settings.system_proxy_at_launch, + http_version: settings.http_version, + start_error: ctx.start_error(), + other_owner, + }) + }) + .await +} + +fn set_recording(ctx: &ProxyCtx, req: SetRecordingRequest) -> Result { + ctx.set_recording(req.enabled); + Ok(ctx.recording()) +} + +fn set_system_proxy_at_launch( + ctx: &ProxyCtx, + req: SetSystemProxyAtLaunchRequest, +) -> Result { + ctx.set_system_proxy_at_launch(req.enabled).map_err(rpc_error)?; + Ok(req.enabled) +} + +fn set_http_version(ctx: &ProxyCtx, req: SetHttpVersionRequest) -> Result { + ctx.set_http_version(req.http_version).map_err(rpc_error)?; + Ok(req.http_version) +} + +async fn take_over(ctx: ProxyCtx, _req: TakeOverRequest) -> Result { + ctx.take_over().await.map_err(rpc_error)?; + Ok(true) +} + +async fn app_icon(_ctx: ProxyCtx, req: AppIconRequest) -> Result, RpcError> { + let png = blocking(move || attribution::app_icon_png(&req.path)).await?; + Ok(png.map(|png| BASE64.encode(png))) +} + +async fn list_workspaces( + ctx: ProxyCtx, + _req: ListWorkspacesRequest, +) -> Result { + blocking(move || { + if !ctx.client_exists() { + return Ok(ListWorkspacesResponse { yaak_found: false, workspaces: Vec::new() }); + } + Ok(ListWorkspacesResponse { yaak_found: true, workspaces: ctx.workspaces()? }) + }) + .await +} + +async fn preview_save(ctx: ProxyCtx, req: PreviewSaveRequest) -> Result { + let flow = find_flow(&ctx, &req.flow_id)?; + let limit = settings(&ctx)?.capture_body_limit; + let body = + bodies::read_body(&ctx.blobs, &flow, FlowBody::Request, limit).await.map_err(rpc_error)?; + let conversion = convert::flow_to_request(&flow, &body); + let request = conversion.request; + let body = request.body_type.as_deref().map(|body_type| match body_type { + "application/json" => "JSON".to_string(), + "text/xml" => "XML".to_string(), + "graphql" => "GraphQL".to_string(), + "application/x-www-form-urlencoded" => { + let fields = request.body.get("form").and_then(|f| f.as_array()).map_or(0, Vec::len); + match fields { + 1 => "form URL-encoded with 1 field".to_string(), + n => format!("form URL-encoded with {n} fields"), + } + } + _ => "text".to_string(), + }); + let authorization = + request.headers.iter().any(|h| h.name.eq_ignore_ascii_case("authorization")); + Ok(SavePreview { + method: flow.method.clone(), + url: flow.url.clone(), + request_url: request.url.clone(), + body_type: request.body_type.clone(), + dropped_headers: conversion.dropped_headers, + kept_headers: request.headers.len(), + body, + warnings: conversion.warnings, + authorization, + }) +} + +async fn save_flow(ctx: ProxyCtx, req: SaveFlowToYaakRequest) -> Result { + let target = SaveFlowRequest { + workspace_id: req.workspace_id, + folder_id: req.folder_id, + name: req.name, + }; + let saved = ctx.save_flow(&req.flow_id, &target).await.map_err(rpc_error)?; + Ok(SavedFlow { request_id: saved.request.id, name: saved.request.name }) +} + +async fn export_har(ctx: ProxyCtx, req: ExportHarRequest) -> Result { + let flows = match req.ids { + Some(ids) => ids.iter().map(|id| flows::get(&ctx.db, id)).collect::, _>>(), + None => { + let filter = flows::FlowFilter { limit: None, ..req.filter.unwrap_or_default() }; + flows::list(&ctx.db, &filter) + } + } + .map_err(rpc_error)?; + let limit = settings(&ctx)?.capture_body_limit; + let har = har::export(&ctx.blobs, &flows, limit).await.map_err(rpc_error)?; + let path = PathBuf::from(req.path); + blocking(move || { + let file = std::fs::File::create(&path) + .map_err(|e| format!("Failed to create {}: {e}", path.display()))?; + let mut out = std::io::BufWriter::new(file); + serde_json::to_writer_pretty(&mut out, &har) + .map_err(|e| format!("Failed to write {}: {e}", path.display()))?; + out.flush().map_err(|e| format!("Failed to write {}: {e}", path.display())) + }) + .await?; + Ok(flows.len()) +} + +fn format_body(_ctx: &ProxyCtx, req: FormatBodyRequest) -> Result { + Ok(yaak_templates::format_json::format_json(&req.text, " ")) +} + +async fn get_flow_body( + ctx: ProxyCtx, + req: GetFlowBodyRequest, +) -> Result { + let flow = find_flow(&ctx, &req.flow_id)?; + let limit = settings(&ctx)?.capture_body_limit; + let body = + bodies::read_body_or_raw(&ctx.blobs, &flow, req.which, limit).await.map_err(rpc_error)?; + Ok(GetFlowBodyResponse { + data: BASE64.encode(&body.data), + truncated: body.truncated, + stored_size: body.stored_size, + decode_error: body.decode_error, + }) +} + +/// Writes a body to a file the person picked: decoded, as the viewer shows it, or as it was on +/// the wire when it fails to decode. +async fn save_flow_body( + ctx: ProxyCtx, + req: SaveFlowBodyRequest, +) -> Result { + let flow = find_flow(&ctx, &req.flow_id)?; + let limit = settings(&ctx)?.capture_body_limit; + let body = + bodies::read_body_or_raw(&ctx.blobs, &flow, req.which, limit).await.map_err(rpc_error)?; + let saved = SaveFlowBodyResponse { + size: body.data.len(), + truncated: body.truncated, + raw: body.decode_error.is_some(), + }; + let path = PathBuf::from(req.path); + blocking(move || { + std::fs::write(&path, &body.data) + .map_err(|e| format!("Failed to write {}: {e}", path.display())) + }) + .await?; + Ok(saved) +} + +/// A body to filter, which fails when the body failed to decode rather than filtering what +/// decoded before the failure. +async fn viewer_body( + ctx: &ProxyCtx, + flow: &Flow, + which: FlowBody, + limit: usize, +) -> Result { + let body = bodies::read_body(&ctx.blobs, flow, which, limit).await.map_err(rpc_error)?; + match body.decode_error { + Some(e) => Err(rpc_error(e)), + None => Ok(body), + } +} + +async fn filter_body( + ctx: ProxyCtx, + req: FilterBodyRequest, +) -> Result { + let flow = find_flow(&ctx, &req.flow_id)?; + let limit = settings(&ctx)?.capture_body_limit; + let body = viewer_body(&ctx, &flow, req.which, limit).await?; + let content_type = find_header(flow.headers(req.which), "content-type"); + // The text the viewer shows, so the filter's results match it + let text = bodies::body_text(&body.data, content_type, body.truncated); + let language = match req.language { + Some(FilterLanguage::JsonPath) => yaak_filter::Language::JsonPath, + Some(FilterLanguage::XPath) => yaak_filter::Language::XPath, + None => yaak_filter::Language::for_content_type(content_type.unwrap_or_default()), + }; + let result = yaak_filter::filter(language, &text, &req.expression); + Ok(FilterBodyResponse { content: result.content, error: result.error }) +} + +async fn ca_status(ctx: ProxyCtx, _req: CaStatusRequest) -> Result { + ca::status(&ctx.ca_dir().join("ca.pem")).await.map_err(rpc_error) +} + +async fn ca_trust(ctx: ProxyCtx, _req: CaTrustRequest) -> Result { + // Creates the CA when the engine has never run, so there is something to trust. + let ca = CaStore::load_or_create(&ctx.ca_dir()) + .map_err(|e| rpc_error(format!("Failed to load proxy CA: {e}")))?; + ca::trust(ca.cert_path()).await.map_err(rpc_error)?; + ctx.forget_passthrough(); + ca::status(ca.cert_path()).await.map_err(rpc_error) +} + +async fn system_proxy_status( + ctx: ProxyCtx, + _req: SystemProxyStatusRequest, +) -> Result { + blocking(move || ctx.system_proxy_status()).await +} + +async fn system_proxy_set( + ctx: ProxyCtx, + req: SystemProxySetRequest, +) -> Result { + blocking(move || ctx.set_system_proxy(req.enabled)).await +} + +async fn helper_register( + ctx: ProxyCtx, + _req: HelperRegisterRequest, +) -> Result { + blocking(move || ctx.register_helper()).await +} + +async fn helper_unregister( + ctx: ProxyCtx, + _req: HelperUnregisterRequest, +) -> Result { + blocking(move || ctx.unregister_helper()).await +} + +async fn blocking( + f: impl FnOnce() -> Result + Send + 'static, +) -> Result { + tokio::task::spawn_blocking(f).await.map_err(rpc_error)?.map_err(rpc_error) +} + +fn settings(ctx: &ProxyCtx) -> Result { + ctx.settings().map_err(rpc_error) +} + +fn find_flow(ctx: &ProxyCtx, flow_id: &str) -> Result { + ctx.db.with_conn(|db| db.find_one::(FlowIden::Id, flow_id)).map_err(rpc_error) +} + +fn rpc_error(e: impl ToString) -> RpcError { + RpcError { message: e.to_string() } } fn list_actions( @@ -135,123 +1244,223 @@ fn list_actions( Ok(ListActionsResponse { actions: crate::actions::all_global_actions() }) } -fn list_models(ctx: &ProxyCtx, _req: ListModelsRequest) -> Result { - ctx.db.with_conn(|db| { - Ok(ListModelsResponse { - http_exchanges: db - .find_all::() - .map_err(|e| RpcError { message: e.to_string() })?, - }) - }) +fn list_flows(ctx: &ProxyCtx, req: flows::FlowFilter) -> Result { + let (flows, total, matching) = flows::page(&ctx.db, &req).map_err(rpc_error)?; + Ok(ListFlowsResponse { flows, total, matching }) +} + +async fn clear_flows(ctx: ProxyCtx, _req: ClearFlowsRequest) -> Result { + blocking(move || ctx.clear_flows()).await } // -- Event loop -- -fn run_event_loop( - rx: std::sync::mpsc::Receiver, +/// Why the engine cannot be taken over from another app. +pub const ANOTHER_APP_OWNS: &str = + "Another Yaak Proxy window owns the engine. Quit it to use this one."; + +/// How long taking over waits for the owner to let go of the engine. +const TAKE_OVER_WAIT: Duration = Duration::from_secs(10); + +/// The longest a new client connection waits for its app to be found before it is served. +const ACCEPT_ATTRIBUTION_WAIT: Duration = Duration::from_millis(20); + +/// The longest a flow's end waits for its app to be found, which almost always it already is: +/// the lookup starts with the request and takes a few milliseconds. +const ATTRIBUTION_WAIT: Duration = Duration::from_millis(100); + +/// A flow between its `RequestStart` and its end, with its bodies being written as they +/// stream. +struct InFlight { + flow: Flow, + /// The client connection it came on. + connection: u64, + request_body: Option, + response_body: Option, +} + +/// Where the event loop stores flows, and whether it does. +struct FlowSink { db: ProxyQueryManager, + blobs: BlobManager, events: RpcEventEmitter, -) { - let mut in_flight: HashMap = HashMap::new(); + attribution: Attribution, + recording: Arc>, +} + +fn run_event_loop(rx: std::sync::mpsc::Receiver, sink: FlowSink) { + let FlowSink { db, blobs, events, attribution, recording } = sink; + let mut in_flight: HashMap = HashMap::new(); while let Ok(event) = rx.recv() { match event { - ProxyEvent::RequestStart { id, method, url, http_version } => { + ProxyEvent::RequestStart { + id, + started_at, + method, + url, + http_version, + connection, + client_addr, + } => { + // Paused when it started: it passes through, and none of its events are kept. + // Judged by its start rather than now, as the loop can run behind. + if !recording.lock().map(|r| r.was_on_at(started_at)).unwrap_or(true) { + continue; + } + // Begun as the connection was accepted; this covers one accepted before the + // engine's port was known. + attribution.begin(connection, client_addr); + let flow_id = Flow::generate_id(); + let request_body = Flow::body_id(&flow_id, FlowBody::Request); + let response_body = Flow::body_id(&flow_id, FlowBody::Response); + let flow = Flow { + id: flow_id, + started_at: DateTime::::from(started_at).naive_utc(), + method, + url, + client_http_version: http_version, + ..Default::default() + }; in_flight.insert( id, - CapturedRequest { - id, - method, - url, - http_version, - status: None, - elapsed_ms: None, - remote_http_version: None, - request_headers: vec![], - request_body: None, - response_headers: vec![], - response_body: None, - response_body_size: 0, - state: RequestState::Sending, - error: None, + InFlight { + flow, + connection, + request_body: Some(BodyChunkWriter::new(blobs.clone(), request_body)), + response_body: Some(BodyChunkWriter::new(blobs.clone(), response_body)), }, ); } ProxyEvent::RequestHeader { id, name, value } => { - if let Some(r) = in_flight.get_mut(&id) { - r.request_headers.push((name, value)); + if let Some(f) = in_flight.get_mut(&id) { + f.flow.request_headers.push(ProxyHeader { name, value }); } } - ProxyEvent::RequestBody { id, body } => { - if let Some(r) = in_flight.get_mut(&id) { - r.request_body = Some(body); + // Counted per chunk too: the response can end, and the flow with it, before the + // upload does. + ProxyEvent::RequestBodyChunk { id, len, data } => { + if let Some(f) = in_flight.get_mut(&id) { + f.flow.request_size += len as i64; + f.flow.request_truncated |= data.len() < len; + write_body_chunk(&mut f.request_body, &data); } } - ProxyEvent::ResponseStart { id, status, http_version, elapsed_ms } => { - if let Some(r) = in_flight.get_mut(&id) { - r.status = Some(status); - r.remote_http_version = Some(http_version); - r.elapsed_ms = Some(elapsed_ms); - r.state = RequestState::Receiving; + ProxyEvent::RequestBodyComplete { id, size, truncated } => { + if let Some(f) = in_flight.get_mut(&id) { + f.flow.request_size = size as i64; + f.flow.request_truncated = truncated; + finish_body(f.request_body.take()); + } + } + ProxyEvent::ResponseStart { id, status, http_version, remote_addr, elapsed_ms } => { + if let Some(f) = in_flight.get_mut(&id) { + f.flow.status = Some(status as i32); + f.flow.upstream_http_version = Some(http_version); + f.flow.remote_addr = remote_addr.map(|a| a.to_string()); + f.flow.elapsed_headers_ms = Some(clamp_ms(elapsed_ms)); } } ProxyEvent::ResponseHeader { id, name, value } => { - if let Some(r) = in_flight.get_mut(&id) { - r.response_headers.push((name, value)); + if let Some(f) = in_flight.get_mut(&id) { + f.flow.response_headers.push(ProxyHeader { name, value }); } } - ProxyEvent::ResponseBodyChunk { .. } => { - // Progress only — no action needed - } - ProxyEvent::ResponseBodyComplete { id, body, size, elapsed_ms } => { - if let Some(mut r) = in_flight.remove(&id) { - r.response_body = body; - r.response_body_size = size; - r.elapsed_ms = r.elapsed_ms.or(Some(elapsed_ms)); - r.state = RequestState::Complete; - write_entry(&db, &events, &r); + ProxyEvent::ResponseBodyChunk { id, data, .. } => { + if let Some(f) = in_flight.get_mut(&id) { + write_body_chunk(&mut f.response_body, &data); } } - ProxyEvent::Error { id, error } => { - if let Some(mut r) = in_flight.remove(&id) { - r.error = Some(error); - r.state = RequestState::Error; - write_entry(&db, &events, &r); + ProxyEvent::ResponseBodyComplete { + id, + size, + truncated, + elapsed_ms, + error, + cancelled, + } => { + if let Some(mut f) = in_flight.remove(&id) { + f.flow.response_size = size as i64; + f.flow.response_truncated = truncated; + f.flow.elapsed_ms = Some(clamp_ms(elapsed_ms)); + f.flow.error = error; + f.flow.cancelled = cancelled; + finish_flow(&db, &events, &attribution, f); + } + } + ProxyEvent::Cancelled { id, elapsed_ms } => { + if let Some(mut f) = in_flight.remove(&id) { + f.flow.elapsed_ms = Some(clamp_ms(elapsed_ms)); + f.flow.cancelled = true; + finish_flow(&db, &events, &attribution, f); + } + } + ProxyEvent::Error { id, error, elapsed_ms } => { + if let Some(mut f) = in_flight.remove(&id) { + f.flow.elapsed_ms = Some(clamp_ms(elapsed_ms)); + f.flow.error = Some(error); + finish_flow(&db, &events, &attribution, f); } } } } } -fn write_entry(db: &ProxyQueryManager, events: &RpcEventEmitter, r: &CapturedRequest) { - let entry = HttpExchange { - url: r.url.clone(), - method: r.method.clone(), - req_headers: r - .request_headers - .iter() - .map(|(n, v)| ProxyHeader { name: n.clone(), value: v.clone() }) - .collect(), - req_body: r.request_body.clone(), - res_status: r.status.map(|s| s as i32), - res_headers: r - .response_headers - .iter() - .map(|(n, v)| ProxyHeader { name: n.clone(), value: v.clone() }) - .collect(), - res_body: r.response_body.clone(), - error: r.error.clone(), - ..Default::default() - }; - db.with_conn(|ctx| match ctx.upsert(&entry, &UpdateSource::Background) { - Ok((saved, created)) => { - events.emit( - "model_write", - &ModelPayload { model: saved, change: ModelChangeEvent::Upsert { created } }, - ); - } - Err(e) => warn!("Failed to write proxy entry: {e}"), +fn clamp_ms(ms: u64) -> i32 { + i32::try_from(ms).unwrap_or(i32::MAX) +} + +/// Stops writing a body after its first failed write, keeping the chunks before it. +fn write_body_chunk(writer: &mut Option, data: &[u8]) { + if data.is_empty() { + return; + } + if let Some(w) = writer.as_mut() + && let Err(e) = w.write(data) + { + warn!("Failed to store proxy body chunk: {e}"); + *writer = None; + } +} + +fn finish_body(writer: Option) { + if let Some(w) = writer + && let Err(e) = w.finish() + { + warn!("Failed to store proxy body chunk: {e}"); + } +} + +fn finish_flow( + db: &ProxyQueryManager, + events: &RpcEventEmitter, + attribution: &Attribution, + f: InFlight, +) { + finish_body(f.request_body); + finish_body(f.response_body); + + let mut flow = f.flow; + flow.content_type = find_header(&flow.response_headers, "content-type").map(String::from); + if let Some(app) = attribution.get(f.connection, ATTRIBUTION_WAIT) { + flow.client_pid = Some(app.pid); + flow.client_name = Some(app.name); + flow.client_bundle_id = app.bundle_id; + flow.client_path = Some(app.path); + } + let written = db.with_tx(|ctx| { + let (saved, created) = ctx.upsert(&flow, &UpdateSource::Background)?; + // Sent while this holds the writer, before the commit, so a clear's event, sent after + // its own commit, always comes after it. + events.emit( + "model_write", + &ModelPayload { model: saved, change: ModelChangeEvent::Upsert { created } }, + ); + Ok::<_, yaak_database::Error>(()) }); + if let Err(e) = written { + warn!("Failed to write proxy flow: {e}"); + } } // -- Router + Schema -- @@ -260,12 +1469,178 @@ define_rpc! { ProxyCtx; commands { execute_action(ActionInvocation) -> bool, - get_proxy_state(GetProxyStateRequest) -> GetProxyStateResponse, + format_body(FormatBodyRequest) -> String, list_actions(ListActionsRequest) -> ListActionsResponse, - list_models(ListModelsRequest) -> ListModelsResponse, + list_flows(flows::FlowFilter) -> ListFlowsResponse, + set_recording(SetRecordingRequest) -> bool, + set_system_proxy_at_launch(SetSystemProxyAtLaunchRequest) -> bool, + set_http_version(SetHttpVersionRequest) -> HttpVersion, + } + async_commands { + app_icon(AppIconRequest) -> Option, + ca_status(CaStatusRequest) -> CaStatus, + ca_trust(CaTrustRequest) -> CaStatus, + clear_flows(ClearFlowsRequest) -> usize, + export_har(ExportHarRequest) -> usize, + filter_body(FilterBodyRequest) -> FilterBodyResponse, + get_flow_body(GetFlowBodyRequest) -> GetFlowBodyResponse, + get_proxy_state(GetProxyStateRequest) -> GetProxyStateResponse, + helper_register(HelperRegisterRequest) -> SystemProxyStatus, + helper_unregister(HelperUnregisterRequest) -> SystemProxyStatus, + list_workspaces(ListWorkspacesRequest) -> ListWorkspacesResponse, + preview_save(PreviewSaveRequest) -> SavePreview, + save_flow(SaveFlowToYaakRequest) -> SavedFlow, + save_flow_body(SaveFlowBodyRequest) -> SaveFlowBodyResponse, + system_proxy_set(SystemProxySetRequest) -> SystemProxyStatus, + system_proxy_status(SystemProxyStatusRequest) -> SystemProxyStatus, + take_over(TakeOverRequest) -> bool, } events { + flows_cleared(FlowsClearedPayload), model_write(ModelPayload), proxy_state_changed(ProxyStatePayload), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn settings_the_password_prompt_parked_keep_it_over_the_helper() { + use yaak_proxy_helper::networksetup::{ProxySetting, ServiceProxy, Snapshot}; + let dir = tempfile::tempdir().unwrap(); + let (events, _rx) = RpcEventEmitter::new(); + let ctx = ProxyCtx::new(dir.path(), events).unwrap(); + assert_eq!(ctx.backend_for(HelperState::Enabled), SystemProxyBackend::Helper); + + // A restore parked an unplugged adapter's settings, at port 0. + let corp = ProxySetting { enabled: true, server: "corp-proxy".into(), port: 3128 }; + let parked = Snapshot { + services: vec![ServiceProxy::new("USB LAN", corp.clone(), corp)], + ..Snapshot::default() + }; + std::fs::write(ctx.snapshot_path(), serde_json::to_vec(&parked).unwrap()).unwrap(); + assert_eq!(macos::saved_port(&ctx.snapshot_path()), None, "routes nothing"); + // Only the password prompt's capture would use them when the adapter is back. + assert_eq!(ctx.backend_for(HelperState::Enabled), SystemProxyBackend::AdminPrompt); + assert_eq!(ctx.backend_for(HelperState::NotRegistered), SystemProxyBackend::AdminPrompt); + } + + #[test] + fn a_request_is_recorded_by_the_state_when_it_started() { + let mut log = RecordingLog::default(); + let before = SystemTime::now(); + std::thread::sleep(Duration::from_millis(5)); + assert!(log.set(false)); + assert!(!log.set(false), "no change"); + std::thread::sleep(Duration::from_millis(5)); + let while_paused = SystemTime::now(); + std::thread::sleep(Duration::from_millis(5)); + assert!(log.set(true)); + // Read after recording resumed, each is judged by when it started. + assert!(log.is_on()); + assert!(log.was_on_at(before)); + assert!(!log.was_on_at(while_paused)); + assert!(log.was_on_at(SystemTime::now())); + } + + /// A flow whose response is `body` under `content_type`, stored in `ctx` + fn flow_with_response(ctx: &ProxyCtx, id: &str, content_type: &str, body: &[u8]) { + let flow = Flow { + id: id.into(), + status: Some(200), + response_size: body.len() as i64, + response_headers: vec![ProxyHeader { + name: "Content-Type".into(), + value: content_type.into(), + }], + ..Default::default() + }; + ctx.db.with_tx(|tx| tx.upsert(&flow, &UpdateSource::Background)).unwrap(); + let mut writer = + BodyChunkWriter::new(ctx.blobs.clone(), Flow::body_id(id, FlowBody::Response)); + writer.write(body).unwrap(); + writer.finish().unwrap(); + } + + /// The filter reads the text the viewer shows: in the body's charset, not UTF-8 alone. + #[tokio::test] + async fn filter_reads_the_body_in_its_charset() { + let dir = tempfile::tempdir().unwrap(); + let (events, _rx) = RpcEventEmitter::new(); + let ctx = ProxyCtx::new(dir.path(), events).unwrap(); + + let xml = "ZürichKraków"; + let utf16: Vec = xml.encode_utf16().flat_map(u16::to_le_bytes).collect(); + flow_with_response(&ctx, "fl_utf16", "application/xml; charset=utf-16le", &utf16); + let json = r#"{"items":[{"name":"Café"},{"name":"Crème brûlée"}]}"#; + let latin1: Vec = json.chars().map(|c| c as u8).collect(); + flow_with_response(&ctx, "fl_latin1", "application/json; charset=iso-8859-1", &latin1); + flow_with_response(&ctx, "fl_unlabeled", "application/json", &latin1); + + let filter = |flow_id: &str, expression: &str, language| FilterBodyRequest { + flow_id: flow_id.into(), + which: FlowBody::Response, + expression: expression.into(), + language: Some(language), + }; + let xpath = filter("fl_utf16", "//city[2]", FilterLanguage::XPath); + let result = filter_body(ctx.clone(), xpath).await.unwrap(); + assert_eq!((result.content.as_str(), result.error), ("Kraków", None)); + for flow_id in ["fl_latin1", "fl_unlabeled"] { + let names = filter(flow_id, "$.items[*].name", FilterLanguage::JsonPath); + let result = filter_body(ctx.clone(), names).await.unwrap(); + assert_eq!(result.error, None, "{flow_id}"); + assert_eq!(result.content, "[\n \"Café\",\n \"Crème brûlée\"\n]", "{flow_id}"); + } + } + + #[tokio::test] + async fn the_app_still_hears_why_a_body_failed_to_decode() { + let dir = tempfile::tempdir().unwrap(); + let (events, _rx) = RpcEventEmitter::new(); + let ctx = ProxyCtx::new(dir.path(), events).unwrap(); + let flow = Flow { + id: "fl_bad".into(), + status: Some(200), + response_headers: vec![ProxyHeader { + name: "Content-Encoding".into(), + value: "gzip".into(), + }], + ..Default::default() + }; + ctx.db.with_tx(|tx| tx.upsert(&flow, &UpdateSource::Background)).unwrap(); + let mut writer = + BodyChunkWriter::new(ctx.blobs.clone(), Flow::body_id("fl_bad", FlowBody::Response)); + writer.write(b"not a gzip stream").unwrap(); + writer.finish().unwrap(); + + // The viewer gets the bytes as they were on the wire, and why they didn't decode. + let req = GetFlowBodyRequest { flow_id: "fl_bad".into(), which: FlowBody::Response }; + let body = get_flow_body(ctx.clone(), req).await.unwrap(); + let error = body.decode_error.unwrap(); + assert!(error.starts_with("Failed to decode Gzip body"), "{error}"); + assert_eq!(BASE64.decode(body.data).unwrap(), b"not a gzip stream"); + assert!(!body.truncated); + + // Saving writes those same bytes. + let path = dir.path().join("body.bin"); + let req = SaveFlowBodyRequest { + flow_id: "fl_bad".into(), + which: FlowBody::Response, + path: path.to_string_lossy().into_owned(), + }; + let saved = save_flow_body(ctx.clone(), req).await.unwrap(); + assert_eq!((saved.size, saved.truncated, saved.raw), (17, false, true)); + assert_eq!(std::fs::read(&path).unwrap(), b"not a gzip stream"); + + let req = FilterBodyRequest { + flow_id: "fl_bad".into(), + which: FlowBody::Response, + expression: "$".into(), + language: None, + }; + assert!(filter_body(ctx, req).await.is_err()); + } +} diff --git a/crates-proxy/yaak-proxy-lib/src/macos.rs b/crates-proxy/yaak-proxy-lib/src/macos.rs new file mode 100644 index 000000000..7a319306b --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/src/macos.rs @@ -0,0 +1,495 @@ +//! The macOS system proxy: `networksetup` pointing the web and secure web proxies of every +//! enabled network service at `127.0.0.1:`. Reading needs no rights; changing needs an +//! administrator, which the privileged helper is, and which the password dialog and `sudo` +//! borrow for one script. Each path saves the settings it replaces and puts them back when +//! turned off. Everything here blocks, the password dialog included, so async callers go +//! through `spawn_blocking`. + +use crate::helper::HelperState; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use ts_rs::TS; +use yaak_proxy_helper::networksetup::{ + NETWORKSETUP, Networksetup, Snapshot, disable_commands, enable_commands, read_services, +}; + +pub use yaak_proxy_helper::networksetup::{ProxySetting, ServiceProxy}; + +/// How a change gets administrator rights. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_rpc.ts")] +pub enum SystemProxyBackend { + /// The privileged helper, approved once in System Settings. No prompt. + Helper, + /// The standard macOS password dialog, through `osascript`. + AdminPrompt, + /// `sudo` in the terminal running the CLI daemon. + Sudo, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct SystemProxyStatus { + /// Every enabled network service sends HTTP and HTTPS to `127.0.0.1:`. + pub enabled: bool, + /// The port checked against. + pub port: u16, + pub services: Vec, + pub backend: SystemProxyBackend, + /// The privileged helper's registration, which decides the backend. + pub helper: HelperState, + /// This platform has a system proxy the app can set. + pub supported: bool, +} + +/// Where the password dialog and `sudo` save the settings they replace, in the proxy data +/// directory. The helper keeps its own, as root. +pub const SNAPSHOT_FILE: &str = "system-proxy-snapshot.json"; + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn load_snapshot(path: &Path) -> Result, String> { + match std::fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map(Some) + .map_err(|e| format!("Failed to read {}: {e}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("Failed to read {}: {e}", path.display())), + } +} + +/// The port the password dialog or `sudo` last pointed the system proxy at, while they have +/// settings saved to put back. +pub fn saved_port(snapshot_path: &Path) -> Option { + // Port 0 holds only settings no service matched, which route nothing. + load_snapshot(snapshot_path).ok().flatten().map(|s| s.port).filter(|p| *p != 0) +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn save_snapshot(path: &Path, snapshot: &Snapshot) -> Result<(), String> { + let json = serde_json::to_vec_pretty(snapshot).map_err(|e| e.to_string())?; + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, json) + .and_then(|()| std::fs::rename(&tmp, path)) + .map_err(|e| format!("Failed to save {}: {e}", path.display())) +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn remove_snapshot(path: &Path) -> Result<(), String> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("Failed to delete {}: {e}", path.display())), + } +} + +/// The password-dialog and `sudo` paths: reads through `ns`, and changes through `run`, which +/// gets administrator rights for the whole list at once. Turning on saves the settings it +/// replaces at `snapshot_path` first; turning off puts them back and deletes the file. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn set_scripted( + ns: &impl Networksetup, + run: impl FnOnce(&[Vec]) -> Result<(), String>, + enabled: bool, + port: u16, + snapshot_path: &Path, +) -> Result<(), String> { + if enabled { + let services = read_services(ns, false)?; + let saved = load_snapshot(snapshot_path)?; + let mut snapshot = saved.clone().unwrap_or_default(); + snapshot.extend(&services, port); + // Saved before anything changes, so a crash after the change still restores. + save_snapshot(snapshot_path, &snapshot)?; + if let Err(e) = run(&enable_commands(&services, port)) { + // Usually cancelled, with nothing changed, and a snapshot kept would go stale, so + // what was there before goes back. Once anything points at us, it is what puts the + // rest back. + let changed = read_services(ns, false)?.iter().any(|s| s.points_at(port)); + if !changed { + match saved { + Some(saved) => save_snapshot(snapshot_path, &saved)?, + None => remove_snapshot(snapshot_path)?, + } + } + return Err(e); + } + return Ok(()); + } + + // Disabled services too: one disabled since it was pointed at us would point at us again + // once re-enabled. + let services = read_services(ns, true)?; + let mut snapshot = load_snapshot(snapshot_path)?; + let (commands, used_up) = disable_commands(&services, snapshot.as_ref(), port); + if commands.is_empty() && !used_up { + return Ok(()); + } + // Saved first, so a restore cut short is finished by the next one. + if let Some(snapshot) = snapshot.as_mut() { + snapshot.mark_restoring(&commands, &services); + save_snapshot(snapshot_path, snapshot)?; + } + if !commands.is_empty() { + run(&commands)?; + } + match snapshot { + Some(snapshot) if used_up => match snapshot.leftover(&services) { + // Kept for when the service is back, and merged into the next capture. + Some(leftover) => { + let names: Vec<&str> = leftover.services.iter().map(|s| s.name.as_str()).collect(); + log::warn!("Keeping saved proxy settings no service matched: {}", names.join(", ")); + save_snapshot(snapshot_path, &leftover) + } + None => remove_snapshot(snapshot_path), + }, + Some(mut snapshot) => { + snapshot.restoring.clear(); + save_snapshot(snapshot_path, &snapshot) + } + None => Ok(()), + } +} + +/// The calls as one `sh` script that stops at the first failure. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn script(commands: &[Vec]) -> String { + commands + .iter() + .map(|args| { + std::iter::once(NETWORKSETUP.to_string()) + .chain(args.iter().map(|a| shell_quote(a))) + .collect::>() + .join(" ") + }) + .collect::>() + .join(" && ") +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', r"'\''")) +} + +/// `script` inside an AppleScript string literal. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn applescript_string(s: &str) -> String { + format!("\"{}\"", s.replace('\\', r"\\").replace('"', "\\\"")) +} + +#[cfg(target_os = "macos")] +mod imp { + use super::*; + use std::process::{Command, Output, Stdio}; + + fn check(output: &Output, program: &str) -> Result<(), String> { + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if stderr.is_empty() { + format!("{program} exited with {}", output.status) + } else { + stderr + }) + } + + pub fn run_privileged(script: &str, backend: SystemProxyBackend) -> Result<(), String> { + match backend { + SystemProxyBackend::Helper => { + Err("The helper changes the system proxy itself, not by script".to_string()) + } + SystemProxyBackend::AdminPrompt => { + let source = format!( + "do shell script {} with prompt {} with administrator privileges", + applescript_string(script), + applescript_string("Yaak Proxy wants to change your system proxy settings."), + ); + let output = Command::new("/usr/bin/osascript") + .args(["-e", &source]) + .output() + .map_err(|e| format!("Failed to run osascript: {e}"))?; + // -128 is the dialog's Cancel button. + if String::from_utf8_lossy(&output.stderr).contains("(-128)") { + return Err("Cancelled in the password dialog".to_string()); + } + check(&output, "osascript") + } + SystemProxyBackend::Sudo => { + // sudo asks on the terminal, so the daemon's own stdin stays attached. + let output = Command::new("/usr/bin/sudo") + .args(["/bin/sh", "-c", script]) + .stdin(Stdio::inherit()) + .output() + .map_err(|e| format!("Failed to run sudo: {e}"))?; + check(&output, "sudo") + } + } + } +} + +/// Which services point where, judged against `port`. +#[cfg(target_os = "macos")] +pub fn status( + port: u16, + backend: SystemProxyBackend, + helper: HelperState, +) -> Result { + let ns = yaak_proxy_helper::networksetup::SystemNetworksetup; + let services = read_services(&ns, false)?; + let enabled = !services.is_empty() + && services.iter().all(|s| s.http.points_at(port) && s.https.points_at(port)); + Ok(SystemProxyStatus { enabled, port, services, backend, helper, supported: true }) +} + +/// Points every enabled service at `127.0.0.1:`, or puts back the settings that were +/// there, turning off any other proxy still pointing at `port`. Turning off with nothing +/// pointing at `port` asks for nothing. `snapshot_path` is where the password dialog and +/// `sudo` keep the settings they replace. +#[cfg(target_os = "macos")] +pub fn set( + enabled: bool, + port: u16, + backend: SystemProxyBackend, + snapshot_path: &Path, +) -> Result<(), String> { + if port == 0 { + return Err("The system proxy needs a port".to_string()); + } + match backend { + SystemProxyBackend::Helper => crate::helper::set(enabled, port), + SystemProxyBackend::AdminPrompt | SystemProxyBackend::Sudo => set_scripted( + &yaak_proxy_helper::networksetup::SystemNetworksetup, + |commands| imp::run_privileged(&script(commands), backend), + enabled, + port, + snapshot_path, + ), + } +} + +#[cfg(not(target_os = "macos"))] +pub fn status( + port: u16, + backend: SystemProxyBackend, + helper: HelperState, +) -> Result { + Ok(SystemProxyStatus { + enabled: false, + port, + services: Vec::new(), + backend, + helper, + supported: false, + }) +} + +#[cfg(not(target_os = "macos"))] +pub fn set( + enabled: bool, + _port: u16, + _backend: SystemProxyBackend, + _snapshot_path: &Path, +) -> Result<(), String> { + if !enabled { + return Ok(()); + } + Err("Setting the system proxy is only supported on macOS".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use yaak_proxy_helper::networksetup::HOST; + use yaak_proxy_helper::networksetup::fake::FakeNetworksetup; + + fn setting(enabled: bool, server: &str, port: u16) -> ProxySetting { + ProxySetting { enabled, server: server.into(), port } + } + + /// Runs the "privileged" commands against the fake, counting the prompts. + fn runner<'a>( + ns: &'a FakeNetworksetup, + prompts: &'a std::cell::Cell, + ) -> impl FnOnce(&[Vec]) -> Result<(), String> + 'a { + move |commands| { + prompts.set(prompts.get() + 1); + yaak_proxy_helper::networksetup::apply(ns, commands) + } + } + + fn fake() -> FakeNetworksetup { + FakeNetworksetup::new(vec![ + ("Wi-Fi", setting(true, "corp-proxy", 3128), setting(false, "corp-proxy", 3129)), + ("Ethernet", ProxySetting::default(), ProxySetting::default()), + ]) + } + + #[test] + fn the_password_path_restores_what_it_replaced() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(SNAPSHOT_FILE); + let ns = fake(); + let before = ns.proxies(); + let prompts = std::cell::Cell::new(0); + + set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap(); + assert!(ns.proxies().iter().all(|s| s.http.points_at(9090) && s.https.points_at(9090))); + assert!(load_snapshot(&path).unwrap().is_some()); + + set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap(); + assert_eq!(ns.proxies()[0], before[0]); + assert!(!ns.proxies()[1].points_at(9090)); + assert!(!path.exists()); + assert_eq!(prompts.get(), 2); + } + + #[test] + fn a_stale_reset_after_a_crash_uses_the_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(SNAPSHOT_FILE); + let ns = fake(); + let before = ns.proxies(); + let prompts = std::cell::Cell::new(0); + set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap(); + + // The app crashed; the next launch resets the configured port, now another one, which + // leaves the snapshot alone, and then the port it saved. + assert_eq!(saved_port(&path), Some(9090)); + set_scripted(&ns, runner(&ns, &prompts), false, 8080, &path).unwrap(); + assert!(path.exists()); + set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap(); + assert_eq!(ns.proxies()[0], before[0]); + assert!(!path.exists()); + } + + #[test] + fn a_stale_reset_at_an_old_port_leaves_live_routing_alone() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(SNAPSHOT_FILE); + let ns = fake(); + let before = ns.proxies(); + let prompts = std::cell::Cell::new(0); + // A crash left a disabled service on 9090; the new engine routes on 9191. + set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap(); + ns.set_enabled("Ethernet", false); + set_scripted(&ns, runner(&ns, &prompts), true, 9191, &path).unwrap(); + + set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap(); + assert!(ns.proxies()[0].http.points_at(9191), "live routing kept"); + assert!(!ns.proxies()[1].points_at(9090), "the stale port cleared"); + assert!(path.exists()); + + set_scripted(&ns, runner(&ns, &prompts), false, 9191, &path).unwrap(); + assert_eq!(ns.proxies()[0], before[0]); + assert!(!path.exists()); + } + + #[test] + fn the_password_path_restores_a_renamed_service_and_keeps_an_unplugged_one() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(SNAPSHOT_FILE); + let ns = fake(); + let before = ns.proxies(); + let prompts = std::cell::Cell::new(0); + set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap(); + ns.rename("Wi-Fi", "Office Wi-Fi"); + ns.remove("Ethernet"); + + set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap(); + let wifi = &ns.proxies()[0]; + assert_eq!((&wifi.http, &wifi.https), (&before[0].http, &before[0].https)); + // Ethernet's settings stay saved, without counting as routing. + let kept = load_snapshot(&path).unwrap().unwrap(); + assert_eq!(kept.services.len(), 1); + assert_eq!(kept.services[0].name, "Ethernet"); + assert_eq!(saved_port(&path), None); + } + + #[test] + fn a_partly_applied_change_keeps_its_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(SNAPSHOT_FILE); + let ns = fake(); + let before = ns.proxies(); + let prompts = std::cell::Cell::new(0); + // Wi-Fi is changed, then Ethernet fails. + *ns.failing.lock().unwrap() = Some("Ethernet".into()); + assert!(set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).is_err()); + assert!(ns.proxies()[0].points_at(9090)); + assert!(path.exists()); + + *ns.failing.lock().unwrap() = None; + set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap(); + assert_eq!(ns.proxies()[0], before[0]); + } + + #[test] + fn turning_off_with_nothing_pointing_at_us_asks_for_nothing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(SNAPSHOT_FILE); + let ns = fake(); + let prompts = std::cell::Cell::new(0); + set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap(); + assert_eq!(prompts.get(), 0); + assert!(ns.changes.lock().unwrap().is_empty()); + } + + #[test] + fn a_cancelled_prompt_keeps_no_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(SNAPSHOT_FILE); + let ns = fake(); + let cancel = |_: &[Vec]| Err("Cancelled in the password dialog".to_string()); + assert!(set_scripted(&ns, cancel, true, 9090, &path).is_err()); + assert!(!path.exists()); + assert!(ns.changes.lock().unwrap().is_empty()); + } + + #[test] + fn turning_on_again_keeps_the_first_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(SNAPSHOT_FILE); + let ns = fake(); + let before = ns.proxies(); + let prompts = std::cell::Cell::new(0); + set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap(); + // A second enable, the engine on a new port, must not save ours as the user's. + set_scripted(&ns, runner(&ns, &prompts), true, 9191, &path).unwrap(); + let cancel = |_: &[Vec]| Err("Cancelled in the password dialog".to_string()); + assert!(set_scripted(&ns, cancel, true, 9191, &path).is_err()); + assert!(path.exists(), "a cancelled re-enable keeps the original snapshot"); + set_scripted(&ns, runner(&ns, &prompts), false, 9191, &path).unwrap(); + assert_eq!(ns.proxies()[0], before[0]); + } + + #[test] + fn a_disabled_service_pointing_at_us_is_turned_off() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(SNAPSHOT_FILE); + let ns = fake(); + let prompts = std::cell::Cell::new(0); + set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap(); + ns.set_enabled("Ethernet", false); + set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap(); + let ethernet = &ns.proxies()[1]; + assert!(!ethernet.points_at(9090)); + assert_eq!(ethernet.http.server, HOST); + } + + #[test] + fn quotes_service_names() { + let commands = vec![vec![ + "-setwebproxystate".to_string(), + "Bob's \"LAN\"".to_string(), + "off".into(), + ]]; + let script = script(&commands); + assert_eq!(script, r#"/usr/sbin/networksetup '-setwebproxystate' 'Bob'\''s "LAN"' 'off'"#); + assert_eq!( + applescript_string(&script), + r#""/usr/sbin/networksetup '-setwebproxystate' 'Bob'\\''s \"LAN\"' 'off'""# + ); + } +} diff --git a/crates-proxy/yaak-proxy-lib/src/models.rs b/crates-proxy/yaak-proxy-lib/src/models.rs index 0b6249ae0..15d35747e 100644 --- a/crates-proxy/yaak-proxy-lib/src/models.rs +++ b/crates-proxy/yaak-proxy-lib/src/models.rs @@ -16,47 +16,117 @@ pub struct ProxyHeader { pub value: String, } +/// One proxied request and its response. The bodies are in `blobs.sqlite`, not the row. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] -#[enum_def(table_name = "http_exchanges")] -pub struct HttpExchange { +#[enum_def(table_name = "flows")] +pub struct Flow { pub id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, - pub url: String, + pub started_at: NaiveDateTime, pub method: String, - pub req_headers: Vec, - pub req_body: Option>, - pub res_status: Option, - pub res_headers: Vec, - pub res_body: Option>, + pub url: String, + /// Version spoken by the client. + pub client_http_version: String, + /// Version spoken with the upstream server. + pub upstream_http_version: Option, + pub remote_addr: Option, + pub status: Option, + /// The response's Content-Type. + pub content_type: Option, + pub request_headers: Vec, + pub response_headers: Vec, + /// Bytes of the request body on the wire, captured or not. + #[ts(type = "number")] + pub request_size: i64, + /// Bytes of the response body on the wire, captured or not. + #[ts(type = "number")] + pub response_size: i64, + /// Only the first `capture_body_limit` bytes of the body are stored. + pub request_truncated: bool, + /// Only the first `capture_body_limit` bytes of the body are stored. + pub response_truncated: bool, + /// Milliseconds from `started_at` to the response headers. + pub elapsed_headers_ms: Option, + /// Milliseconds from `started_at` to the end of the response. + pub elapsed_ms: Option, pub error: Option, + /// The client gave up before the response ended, by resetting its HTTP/2 stream or closing + /// the connection. Not an error. + pub cancelled: bool, + /// The id of the Yaak request this flow was last saved as. + pub saved_request_id: Option, + /// The process that opened the client connection, where the platform can tell (macOS). + pub client_pid: Option, + /// Its app's name, or its executable's outside an app: "Google Chrome", "curl". + pub client_name: Option, + /// Its app's bundle id. + pub client_bundle_id: Option, + /// Its app bundle's path, or its executable's outside an app. + pub client_path: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_models.ts")] +pub enum FlowBody { + Request, + Response, +} + +impl Flow { + /// The blob store id of one of a flow's bodies. + pub fn body_id(flow_id: &str, body: FlowBody) -> String { + match body { + FlowBody::Request => format!("{flow_id}.request"), + FlowBody::Response => format!("{flow_id}.response"), + } + } + + pub fn headers(&self, body: FlowBody) -> &[ProxyHeader] { + match body { + FlowBody::Request => &self.request_headers, + FlowBody::Response => &self.response_headers, + } + } + + pub fn truncated(&self, body: FlowBody) -> bool { + match body { + FlowBody::Request => self.request_truncated, + FlowBody::Response => self.response_truncated, + } + } +} + +pub fn find_header<'a>(headers: &'a [ProxyHeader], name: &str) -> Option<&'a str> { + headers.iter().find(|h| h.name.eq_ignore_ascii_case(name)).map(|h| h.value.as_str()) } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] pub struct ModelPayload { - pub model: HttpExchange, + pub model: Flow, pub change: ModelChangeEvent, } -impl UpsertModelInfo for HttpExchange { +impl UpsertModelInfo for Flow { fn table_name() -> impl IntoTableRef + IntoIden { - HttpExchangeIden::Table + FlowIden::Table } fn id_column() -> impl IntoIden + Eq + Clone { - HttpExchangeIden::Id + FlowIden::Id } fn generate_id() -> String { - generate_prefixed_id("he") + generate_prefixed_id("fl") } fn order_by() -> (impl IntoColumnRef, Order) { - (HttpExchangeIden::CreatedAt, Order::Desc) + (FlowIden::StartedAt, Order::Desc) } fn get_id(&self) -> String { @@ -67,32 +137,62 @@ impl UpsertModelInfo for HttpExchange { self, source: &UpdateSource, ) -> DbResult)>> { - use HttpExchangeIden::*; + use FlowIden::*; Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), - (Url, self.url.into()), + (StartedAt, self.started_at.into()), (Method, self.method.into()), - (ReqHeaders, serde_json::to_string(&self.req_headers)?.into()), - (ReqBody, self.req_body.into()), - (ResStatus, self.res_status.into()), - (ResHeaders, serde_json::to_string(&self.res_headers)?.into()), - (ResBody, self.res_body.into()), + (Url, self.url.into()), + (ClientHttpVersion, self.client_http_version.into()), + (UpstreamHttpVersion, self.upstream_http_version.into()), + (RemoteAddr, self.remote_addr.into()), + (Status, self.status.into()), + (ContentType, self.content_type.into()), + (RequestHeaders, serde_json::to_string(&self.request_headers)?.into()), + (ResponseHeaders, serde_json::to_string(&self.response_headers)?.into()), + (RequestSize, self.request_size.into()), + (ResponseSize, self.response_size.into()), + (RequestTruncated, self.request_truncated.into()), + (ResponseTruncated, self.response_truncated.into()), + (ElapsedHeadersMs, self.elapsed_headers_ms.into()), + (ElapsedMs, self.elapsed_ms.into()), (Error, self.error.into()), + (Cancelled, self.cancelled.into()), + (SavedRequestId, self.saved_request_id.into()), + (ClientPid, self.client_pid.into()), + (ClientName, self.client_name.into()), + (ClientBundleId, self.client_bundle_id.into()), + (ClientPath, self.client_path.into()), ]) } fn update_columns() -> Vec { vec![ - HttpExchangeIden::UpdatedAt, - HttpExchangeIden::Url, - HttpExchangeIden::Method, - HttpExchangeIden::ReqHeaders, - HttpExchangeIden::ReqBody, - HttpExchangeIden::ResStatus, - HttpExchangeIden::ResHeaders, - HttpExchangeIden::ResBody, - HttpExchangeIden::Error, + FlowIden::UpdatedAt, + FlowIden::StartedAt, + FlowIden::Method, + FlowIden::Url, + FlowIden::ClientHttpVersion, + FlowIden::UpstreamHttpVersion, + FlowIden::RemoteAddr, + FlowIden::Status, + FlowIden::ContentType, + FlowIden::RequestHeaders, + FlowIden::ResponseHeaders, + FlowIden::RequestSize, + FlowIden::ResponseSize, + FlowIden::RequestTruncated, + FlowIden::ResponseTruncated, + FlowIden::ElapsedHeadersMs, + FlowIden::ElapsedMs, + FlowIden::Error, + FlowIden::Cancelled, + FlowIden::SavedRequestId, + FlowIden::ClientPid, + FlowIden::ClientName, + FlowIden::ClientBundleId, + FlowIden::ClientPath, ] } @@ -100,20 +200,43 @@ impl UpsertModelInfo for HttpExchange { where Self: Sized, { - let req_headers: String = r.get("req_headers")?; - let res_headers: String = r.get("res_headers")?; + let request_headers: String = r.get("request_headers")?; + let response_headers: String = r.get("response_headers")?; Ok(Self { id: r.get("id")?, created_at: r.get("created_at")?, updated_at: r.get("updated_at")?, - url: r.get("url")?, + started_at: r.get("started_at")?, method: r.get("method")?, - req_headers: serde_json::from_str(&req_headers).unwrap_or_default(), - req_body: r.get("req_body")?, - res_status: r.get("res_status")?, - res_headers: serde_json::from_str(&res_headers).unwrap_or_default(), - res_body: r.get("res_body")?, + url: r.get("url")?, + client_http_version: r.get("client_http_version")?, + upstream_http_version: r.get("upstream_http_version")?, + remote_addr: r.get("remote_addr")?, + status: r.get("status")?, + content_type: r.get("content_type")?, + request_headers: serde_json::from_str(&request_headers).unwrap_or_default(), + response_headers: serde_json::from_str(&response_headers).unwrap_or_default(), + request_size: r.get("request_size")?, + response_size: r.get("response_size")?, + request_truncated: r.get("request_truncated")?, + response_truncated: r.get("response_truncated")?, + elapsed_headers_ms: r.get("elapsed_headers_ms")?, + elapsed_ms: r.get("elapsed_ms")?, error: r.get("error")?, + cancelled: added_column(r, "cancelled")?.unwrap_or_default(), + saved_request_id: added_column(r, "saved_request_id")?, + client_pid: added_column(r, "client_pid")?, + client_name: added_column(r, "client_name")?, + client_bundle_id: added_column(r, "client_bundle_id")?, + client_path: added_column(r, "client_path")?, }) } } + +/// A column added by a later migration, absent from a database read before it has run. +fn added_column(r: &Row, name: &str) -> rusqlite::Result> { + match r.get(name) { + Err(rusqlite::Error::InvalidColumnName(_)) => Ok(None), + v => v, + } +} diff --git a/crates-proxy/yaak-proxy-lib/src/save.rs b/crates-proxy/yaak-proxy-lib/src/save.rs new file mode 100644 index 000000000..72bf99592 --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/src/save.rs @@ -0,0 +1,148 @@ +//! Saving a flow into the Yaak client's database. It is opened as a lifecycle guest, as the CLI +//! opens it, since Yaak may have it open too, and the request is written with +//! `UpdateSource::Sync` so a running Yaak picks it up from its model-change poller. + +use crate::bodies::DecodedBody; +use crate::convert::{self, DroppedHeader}; +use crate::models::Flow; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use ts_rs::TS; +use yaak_models::models::HttpRequest; +use yaak_models::query_manager::QueryManager; +use yaak_models::util::UpdateSource; + +/// Yaak's data directory, where its `db.sqlite` is. A debug build pairs with a debug Yaak. +pub fn client_data_dir() -> PathBuf { + let app_id = if cfg!(debug_assertions) { "app.yaak.desktop.dev" } else { "app.yaak.desktop" }; + yaak_common::data_dir::resolve_data_dir(app_id) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct WorkspaceSummary { + pub id: String, + pub name: String, + pub folders: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct FolderSummary { + pub id: String, + pub name: String, + /// The folder this one is in, if any. + pub folder_id: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveFlowRequest { + pub workspace_id: String, + pub folder_id: Option, + /// None or blank leaves the request unnamed, and Yaak shows its URL. + pub name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveFlowResponse { + pub request: HttpRequest, + pub dropped_headers: Vec, + pub warnings: Vec, +} + +pub struct ClientDb { + query_manager: QueryManager, +} + +impl ClientDb { + /// Yaak has run on this machine, so there is a database to save into. + pub fn exists(data_dir: &Path) -> bool { + data_dir.join("db.sqlite").exists() + } + + /// Opens `db.sqlite` in Yaak's data directory `data_dir`. Fails when there is none, rather + /// than creating a database for a Yaak that has never run. + pub fn open(data_dir: &Path) -> Result { + let db_path = data_dir.join("db.sqlite"); + if !Self::exists(data_dir) { + return Err(format!( + "Yaak's database was not found at {}. Install Yaak and open it once.", + db_path.display() + )); + } + let (query_manager, blob_manager, _rx) = + yaak_models::init_standalone(&db_path, data_dir.join("blobs.sqlite")) + .map_err(|e| format!("Failed to open Yaak's database: {e}"))?; + query_manager + .with_tx(|tx| { + yaak_lifecycle::on_launch(&yaak_lifecycle::Host::guest(), tx, &blob_manager) + }) + .map_err(|e| format!("Failed to open Yaak's database: {e}"))?; + Ok(Self { query_manager }) + } + + pub fn workspaces(&self) -> Result, String> { + let db = self.query_manager.connect(); + let workspaces = db.list_workspaces().map_err(|e| e.to_string())?; + workspaces + .into_iter() + .map(|w| { + let folders = db.list_folders(&w.id).map_err(|e| e.to_string())?; + Ok(WorkspaceSummary { + id: w.id, + name: w.name, + folders: folders + .into_iter() + .map(|f| FolderSummary { id: f.id, name: f.name, folder_id: f.folder_id }) + .collect(), + }) + }) + .collect() + } + + /// Writes `flow`, with `body` its decoded request body, as a new request. + pub fn save( + &self, + flow: &Flow, + body: &DecodedBody, + target: &SaveFlowRequest, + ) -> Result { + let db = self.query_manager.connect(); + db.get_workspace(&target.workspace_id) + .map_err(|_| format!("No workspace {} in Yaak", target.workspace_id))?; + if let Some(folder_id) = &target.folder_id { + let folder = + db.get_folder(folder_id).map_err(|_| format!("No folder {folder_id} in Yaak"))?; + if folder.workspace_id != target.workspace_id { + return Err(format!( + "Folder {folder_id} is not in workspace {}", + target.workspace_id + )); + } + } + + let conversion = convert::flow_to_request(flow, body); + let mut request = conversion.request; + request.workspace_id = target.workspace_id.clone(); + request.folder_id = target.folder_id.clone(); + if let Some(name) = target.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) { + request.name = name.to_string(); + } + // First in its folder, as the client places a new request. + request.sort_priority = -(chrono::Utc::now().timestamp_millis() as f64); + + let request = self + .query_manager + .with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync)) + .map_err(|e| format!("Failed to save the request to Yaak: {e}"))?; + Ok(SaveFlowResponse { + request, + dropped_headers: conversion.dropped_headers, + warnings: conversion.warnings, + }) + } +} diff --git a/crates-proxy/yaak-proxy-lib/src/settings.rs b/crates-proxy/yaak-proxy-lib/src/settings.rs new file mode 100644 index 000000000..59159ed3f --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/src/settings.rs @@ -0,0 +1,147 @@ +use serde::{Deserialize, Serialize}; +use std::path::Path; +use ts_rs::TS; +use yaak_proxy::DEFAULT_CAPTURE_BODY_LIMIT; + +/// `settings.json` in the proxy data directory. Missing keys take their defaults. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ProxySettings { + pub port: u16, + /// Point this Mac's web traffic at the engine as it starts. It is turned off again as the + /// engine stops, and can be turned on and off any time between. Read from the earlier + /// `systemProxy` too, so a saved choice to keep it off carries over. + #[serde(alias = "systemProxy")] + pub system_proxy_at_launch: bool, + /// Bytes of each request and response body stored per flow. + pub capture_body_limit: usize, + /// The HTTP versions offered to clients in intercepted HTTPS. + pub http_version: HttpVersion, +} + +impl Default for ProxySettings { + fn default() -> Self { + Self { + port: 9090, + system_proxy_at_launch: true, + capture_body_limit: DEFAULT_CAPTURE_BODY_LIMIT, + http_version: HttpVersion::Auto, + } + } +} + +/// The HTTP versions offered to clients in intercepted HTTPS. Upstream, the engine speaks +/// whichever the server picks either way. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub enum HttpVersion { + /// HTTP/2 or HTTP/1.1, as the client prefers. + #[default] + Auto, + /// HTTP/1.1 alone, for clients that misbehave on HTTP/2. + Http1Only, +} + +impl ProxySettings { + /// The settings at `path`, or the defaults when there is no file. + pub fn load(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(contents) => serde_json::from_str(&contents) + .map_err(|e| format!("Invalid settings in {}: {e}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(e) => Err(format!("Failed to read {}: {e}", path.display())), + } + } + + pub fn save(&self, path: &Path) -> Result<(), String> { + let contents = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?; + std::fs::write(path, contents) + .map_err(|e| format!("Failed to write {}: {e}", path.display())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_without_a_file() { + let dir = tempfile::tempdir().unwrap(); + let settings = ProxySettings::load(&dir.path().join("settings.json")).unwrap(); + assert_eq!(settings.port, 9090); + assert!(settings.system_proxy_at_launch); + assert_eq!(settings.capture_body_limit, 10 * 1024 * 1024); + } + + #[test] + fn defaults_for_missing_keys() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + std::fs::write(&path, r#"{"port": 8888}"#).unwrap(); + let settings = ProxySettings::load(&path).unwrap(); + assert_eq!(settings, ProxySettings { port: 8888, ..Default::default() }); + } + + #[test] + fn round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let settings = ProxySettings { + port: 8080, + system_proxy_at_launch: false, + capture_body_limit: 1024, + http_version: HttpVersion::Http1Only, + }; + settings.save(&path).unwrap(); + assert_eq!(ProxySettings::load(&path).unwrap(), settings); + + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "port": 8080, + "systemProxyAtLaunch": false, + "captureBodyLimit": 1024, + "httpVersion": "http1Only", + }) + ); + } + + #[test] + fn http_version_defaults_to_auto() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + std::fs::write(&path, r#"{"port": 8888}"#).unwrap(); + assert_eq!(ProxySettings::load(&path).unwrap().http_version, HttpVersion::Auto); + std::fs::write(&path, r#"{"httpVersion": "auto"}"#).unwrap(); + assert_eq!(ProxySettings::load(&path).unwrap().http_version, HttpVersion::Auto); + } + + #[test] + fn the_earlier_system_proxy_key_carries_over() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + std::fs::write(&path, r#"{"port": 9090, "systemProxy": false}"#).unwrap(); + assert!(!ProxySettings::load(&path).unwrap().system_proxy_at_launch); + std::fs::write(&path, r#"{"systemProxy": true}"#).unwrap(); + assert!(ProxySettings::load(&path).unwrap().system_proxy_at_launch); + + // Saved again under the new key only. + let settings = ProxySettings { system_proxy_at_launch: false, ..Default::default() }; + settings.save(&path).unwrap(); + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(json["systemProxyAtLaunch"], false); + assert!(json.get("systemProxy").is_none()); + } + + #[test] + fn invalid_json_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + std::fs::write(&path, "{").unwrap(); + assert!(ProxySettings::load(&path).is_err()); + } +} diff --git a/crates-proxy/yaak-proxy-lib/tests/control.rs b/crates-proxy/yaak-proxy-lib/tests/control.rs new file mode 100644 index 000000000..e5a402ad3 --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/tests/control.rs @@ -0,0 +1,763 @@ +use std::convert::Infallible; +use std::net::SocketAddr; +use std::path::Path; +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use http_body_util::Full; +use hyper::body::Incoming; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::{Request, Response}; +use hyper_util::rt::TokioIo; +use serde_json::{Value, json}; +use tokio::net::TcpListener; +use yaak_models::models::{Folder, Workspace}; +use yaak_models::util::UpdateSource; +use yaak_proxy_lib::client::{ControlClient, ProxyConnection}; +use yaak_proxy_lib::control::{self, ControlInfo, ControlServer, Owner}; +use yaak_proxy_lib::flows::{BodyEncoding, FlowFilter}; +use yaak_proxy_lib::models::{Flow, FlowBody}; +use yaak_proxy_lib::save::SaveFlowRequest; +use yaak_proxy_lib::settings::ProxySettings; +use yaak_proxy_lib::{ProxyCtx, ProxyState}; +use yaak_rpc::RpcEventEmitter; + +async fn route(_req: Request) -> Result>, Infallible> { + Ok(Response::builder() + .header("content-type", "application/json") + .body(Full::from(r#"{"id":"ch_1","amount":42}"#)) + .unwrap()) +} + +async fn spawn_upstream() -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let (tcp, _) = listener.accept().await.unwrap(); + tokio::spawn( + http1::Builder::new().serve_connection(TokioIo::new(tcp), service_fn(route)), + ); + } + }); + addr +} + +/// A context over `dir` whose engine picks a free port, saving into Yaak's data in `client`. +fn ctx(dir: &Path, client: &Path) -> ProxyCtx { + let settings = ProxySettings { port: 0, system_proxy_at_launch: false, ..Default::default() }; + settings.save(&dir.join("settings.json")).unwrap(); + let (events, _rx) = RpcEventEmitter::new(); + ProxyCtx::new(dir, events).unwrap().with_client_data_dir(client) +} + +/// Sends one POST through the engine and waits for its flow. +async fn capture_flow(ctx: &ProxyCtx) -> Flow { + let upstream = spawn_upstream().await; + let url = format!("http://{upstream}/v1/charges?expand=customer"); + let client = reqwest::Client::builder() + .proxy(reqwest::Proxy::http(format!("http://127.0.0.1:{}", ctx.port().unwrap())).unwrap()) + .build() + .unwrap(); + let resp = client + .post(&url) + .header("content-type", "application/json") + .header("authorization", "Bearer sk_test_123") + .body(r#"{"amount":42}"#) + .send() + .await + .unwrap(); + assert_eq!(resp.text().await.unwrap(), r#"{"id":"ch_1","amount":42}"#); + + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let flows = ctx.db.with_conn(|db| db.find_all::().unwrap()); + if let Some(flow) = flows.into_iter().find(|f| f.url == url) { + return flow; + } + assert!(Instant::now() < deadline, "no flow for {url}"); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +/// A process id that is certainly not running. +fn dead_pid() -> u32 { + let mut child = if cfg!(windows) { + std::process::Command::new("cmd").args(["/C", "exit"]).spawn().unwrap() + } else { + std::process::Command::new("true").spawn().unwrap() + }; + let pid = child.id(); + child.wait().unwrap(); + pid +} + +#[tokio::test(flavor = "multi_thread")] +async fn round_trip() { + let dir = tempfile::tempdir().unwrap(); + let client_dir = tempfile::tempdir().unwrap(); + let ctx = ctx(dir.path(), client_dir.path()); + let server = ControlServer::start(ctx.clone()).unwrap(); + + let info = control::read(dir.path()).unwrap(); + assert_eq!(&info, server.info()); + assert_eq!((info.pid, info.owner, info.proxy_port), (std::process::id(), Owner::App, None)); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(dir.path().join("control.json")).unwrap().permissions(); + assert_eq!(mode.mode() & 0o777, 0o600); + } + + // The token is required. + let http = reqwest::Client::builder().no_proxy().build().unwrap(); + let url = format!("{}/status", info.base_url()); + assert_eq!(http.get(&url).send().await.unwrap().status(), 401); + let wrong = http.get(&url).bearer_auth("not-the-token").send().await.unwrap(); + assert_eq!(wrong.status(), 401); + + let ProxyConnection::Live(client) = ProxyConnection::connect(dir.path()).unwrap() else { + panic!("expected the live owner"); + }; + let status = client.status().await.unwrap(); + assert_eq!( + (status.state, status.proxy_port, status.flow_count), + (ProxyState::Stopped, None, 0) + ); + + let status = client.start(None).await.unwrap(); + assert_eq!(status.state, ProxyState::Running); + assert_eq!(status.proxy_port, ctx.port()); + assert_eq!(control::read(dir.path()).unwrap().proxy_port, ctx.port()); + + let flow = capture_flow(&ctx).await; + + let flows = client.flows(&FlowFilter::default()).await.unwrap(); + assert_eq!(flows.iter().map(|f| &f.id).collect::>(), vec![&flow.id]); + let following = FlowFilter { after: Some(flow.id.clone()), ..Default::default() }; + assert!(client.flows(&following).await.unwrap().is_empty()); + let filter = FlowFilter { status: Some("4xx".into()), ..Default::default() }; + assert!(client.flows(&filter).await.unwrap().is_empty()); + let search = FlowFilter { q: Some("CHARGES post 2xx".into()), ..Default::default() }; + assert_eq!(client.flows(&search).await.unwrap().len(), 1); + let search = FlowFilter { q: Some("charges get".into()), ..Default::default() }; + assert!(client.flows(&search).await.unwrap().is_empty()); + + let detail = client.flow(&flow.id, 8).await.unwrap(); + assert_eq!(detail.flow.method, "POST"); + let request_body = detail.request_body.unwrap(); + assert_eq!((request_body.data.as_str(), request_body.truncated), (r#"{"amount"#, true)); + assert_eq!(request_body.encoding, BodyEncoding::Utf8); + let response = client.body(&flow.id, FlowBody::Response).await.unwrap(); + assert_eq!(response.data, br#"{"id":"ch_1","amount":42}"#); + assert!(!response.truncated); + + let missing = client.flow("fl_missing", 8).await.unwrap_err(); + assert_eq!(missing, "No flow fl_missing"); + + let har = client.har(&FlowFilter::default()).await.unwrap(); + assert_eq!(har.log.entries.len(), 1); + assert_eq!(har.log.entries[0].request.post_data.as_ref().unwrap().text, r#"{"amount":42}"#); + + assert!(client.ca_pem().await.unwrap().starts_with("-----BEGIN CERTIFICATE-----")); + + let status = client.stop().await.unwrap(); + assert_eq!( + (status.state, status.proxy_port, status.flow_count), + (ProxyState::Stopped, None, 1) + ); + assert_eq!(control::read(dir.path()).unwrap().proxy_port, None); + + let clear = http + .post(format!("{}/flows/clear", info.base_url())) + .bearer_auth(&info.token) + .send() + .await + .unwrap(); + assert_eq!(clear.json::().await.unwrap().cleared, 1); + assert!(client.flows(&FlowFilter::default()).await.unwrap().is_empty()); + assert!(client.body(&flow.id, FlowBody::Response).await.is_err()); + + drop(server); + assert!(!dir.path().join("control.json").exists()); + assert!(control::live_owner(dir.path()).is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn one_owner_at_a_time() { + let dir = tempfile::tempdir().unwrap(); + let client_dir = tempfile::tempdir().unwrap(); + let ctx = ctx(dir.path(), client_dir.path()); + let first = ControlServer::start(ctx.clone()).unwrap(); + + let err = ControlServer::start(ctx.clone()).err().unwrap(); + assert!(err.contains("already running in Yaak Proxy"), "{err}"); + assert_eq!(control::read(dir.path()).unwrap(), *first.info()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stale_control_file_is_replaced() { + let dir = tempfile::tempdir().unwrap(); + let client_dir = tempfile::tempdir().unwrap(); + let ctx = ctx(dir.path(), client_dir.path()).with_owner(Owner::Cli); + + // A daemon that died without cleaning up, and one whose pid is alive (ours) but whose + // control port answers nothing, as after the OS reuses a pid. + let unused_port = TcpListener::bind("127.0.0.1:0").await.unwrap().local_addr().unwrap().port(); + for pid in [dead_pid(), std::process::id()] { + let stale = ControlInfo { + pid, + control_port: unused_port, + proxy_port: Some(9090), + token: "stale".into(), + owner: Owner::Cli, + started_at: chrono::Utc::now(), + }; + std::fs::write(dir.path().join("control.json"), serde_json::to_vec(&stale).unwrap()) + .unwrap(); + assert!(control::live_owner(dir.path()).is_none()); + assert!(!dir.path().join("control.json").exists(), "stale file left for pid {pid}"); + + std::fs::write(dir.path().join("control.json"), serde_json::to_vec(&stale).unwrap()) + .unwrap(); + let server = ControlServer::start(ctx.clone()).unwrap(); + let info = control::read(dir.path()).unwrap(); + assert_eq!((info.pid, info.owner), (std::process::id(), Owner::Cli)); + assert_ne!(info.token, "stale"); + drop(server); + } + + // A file that does not parse is never a claim in progress, since claims are published whole. + std::fs::write(dir.path().join("control.json"), "{\"pid\": 12").unwrap(); + let server = ControlServer::start(ctx.clone()).unwrap(); + assert_eq!(control::read(dir.path()).unwrap(), *server.info()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stop_reaches_a_cli_owner() { + let dir = tempfile::tempdir().unwrap(); + let client_dir = tempfile::tempdir().unwrap(); + let ctx = ctx(dir.path(), client_dir.path()).with_owner(Owner::Cli); + let mut server = ControlServer::start(ctx.clone()).unwrap(); + let stop_rx = server.take_stop_rx().unwrap(); + + // The daemon exits as soon as it hears the stop, and the stop is still answered. + let daemon = std::thread::spawn(move || { + stop_rx.recv().unwrap(); + drop(server); + }); + let client = ControlClient::new(control::read(dir.path()).unwrap()); + client.start(None).await.unwrap(); + let status = client.stop().await.unwrap(); + assert_eq!(status.state, ProxyState::Stopped); + daemon.join().unwrap(); + assert_eq!(ctx.port(), None); + assert!(!dir.path().join("control.json").exists()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn offline_reads_proxy_db() { + let dir = tempfile::tempdir().unwrap(); + let client_dir = tempfile::tempdir().unwrap(); + let flow = { + let ctx = ctx(dir.path(), client_dir.path()); + ctx.start().unwrap(); + let flow = capture_flow(&ctx).await; + ctx.stop().unwrap(); + flow + }; + + let connection = ProxyConnection::connect(dir.path()).unwrap(); + assert!(matches!(connection, ProxyConnection::Offline(_))); + let flows = connection.flows(&FlowFilter::default()).await.unwrap(); + assert_eq!(flows.iter().map(|f| &f.id).collect::>(), vec![&flow.id]); + let detail = connection.flow(&flow.id, 1024).await.unwrap(); + assert_eq!(detail.response_body.unwrap().data, r#"{"id":"ch_1","amount":42}"#); + let body = connection.body(&flow.id, FlowBody::Request).await.unwrap(); + assert_eq!(body.data, br#"{"amount":42}"#); + let posts = FlowFilter { method: Some("post".into()), ..Default::default() }; + assert_eq!(connection.har(&posts).await.unwrap().log.entries.len(), 1); + + let empty = tempfile::tempdir().unwrap(); + let err = ProxyConnection::connect(empty.path()).err().unwrap(); + assert!(err.starts_with("No captured traffic yet"), "{err}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn save_writes_the_request_into_yaak() { + let dir = tempfile::tempdir().unwrap(); + let client_dir = tempfile::tempdir().unwrap(); + + // Yaak's database, with a workspace and a folder in it. + let (query_manager, _blobs, _rx) = yaak_models::init_standalone( + client_dir.path().join("db.sqlite"), + client_dir.path().join("blobs.sqlite"), + ) + .unwrap(); + let (workspace, folder) = query_manager + .with_tx(|tx| { + let workspace = tx.upsert_workspace( + &Workspace { name: "Payments".into(), ..Default::default() }, + &UpdateSource::Background, + )?; + let folder = tx.upsert_folder( + &Folder { + name: "Stripe".into(), + workspace_id: workspace.id.clone(), + ..Default::default() + }, + &UpdateSource::Background, + )?; + Ok::<_, yaak_models::error::Error>((workspace, folder)) + }) + .unwrap(); + + let ctx = ctx(dir.path(), client_dir.path()); + let _server = ControlServer::start(ctx.clone()).unwrap(); + ctx.start().unwrap(); + let flow = capture_flow(&ctx).await; + let client = ControlClient::new(control::read(dir.path()).unwrap()); + + let workspaces = client.workspaces().await.unwrap(); + assert_eq!(workspaces.len(), 1); + assert_eq!( + (workspaces[0].id.as_str(), workspaces[0].name.as_str()), + (workspace.id.as_str(), "Payments") + ); + assert_eq!(workspaces[0].folders[0].id, folder.id); + + let target = SaveFlowRequest { + workspace_id: workspace.id.clone(), + folder_id: Some(folder.id.clone()), + name: Some("Create charge".into()), + }; + let saved = client.save(&flow.id, &target).await.unwrap(); + let dropped: Vec<&str> = saved.dropped_headers.iter().map(|h| h.name.as_str()).collect(); + assert!(dropped.contains(&"host") && dropped.contains(&"content-length"), "{dropped:?}"); + assert_eq!( + saved.warnings, + vec!["The Authorization header is saved as captured, in plain text."] + ); + + // The row is in Yaak's database, written as a sync so a running Yaak picks it up. + let request = query_manager.connect().get_http_request(&saved.request.id).unwrap(); + assert_eq!(request.name, "Create charge"); + assert_eq!( + (request.workspace_id, request.folder_id), + (workspace.id.clone(), Some(folder.id.clone())) + ); + assert_eq!(request.method, "POST"); + assert!(request.url.ends_with("/v1/charges"), "{}", request.url); + assert_eq!(request.url_parameters[0].name, "expand"); + assert_eq!(request.body_type.as_deref(), Some("application/json")); + assert_eq!(request.body["text"], r#"{"amount":42}"#); + let auth = request.headers.iter().find(|h| h.name == "authorization").unwrap(); + assert_eq!(auth.value, "Bearer sk_test_123"); + let db = rusqlite::Connection::open(client_dir.path().join("db.sqlite")).unwrap(); + let source: String = db + .query_row( + "SELECT update_source FROM model_changes WHERE model_id = ?", + [&saved.request.id], + |r| r.get(0), + ) + .unwrap(); + assert!(source.contains("sync"), "{source}"); + + // The flow remembers what it was saved as. + let flow = yaak_proxy_lib::flows::get(&ctx.db, &flow.id).unwrap(); + assert_eq!(flow.saved_request_id, Some(saved.request.id)); + + // Without a name, through the control API as the CLI saves, it stays unnamed, as Yaak + // leaves a new request, so Yaak shows its URL. + for name in [None, Some(" ".to_string())] { + let target = SaveFlowRequest { workspace_id: workspace.id.clone(), folder_id: None, name }; + let saved = client.save(&flow.id, &target).await.unwrap(); + let request = query_manager.connect().get_http_request(&saved.request.id).unwrap(); + assert_eq!(request.name, ""); + } + + let wrong = SaveFlowRequest { workspace_id: "wk_missing".into(), ..Default::default() }; + assert!(client.save(&flow.id, &wrong).await.unwrap_err().contains("No workspace")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stale_removal_waits_for_the_claim_lock() { + let dir = tempfile::tempdir().unwrap(); + let stale = ControlInfo { + pid: dead_pid(), + control_port: 1, + proxy_port: None, + token: "stale".into(), + owner: Owner::Cli, + started_at: chrono::Utc::now(), + }; + let path = dir.path().join("control.json"); + std::fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap(); + + // Another process mid-claim holds the lock; it may be about to replace the stale file. + let lock = std::fs::File::create(dir.path().join("control.lock")).unwrap(); + lock.lock().unwrap(); + let data_dir = dir.path().to_path_buf(); + let cleaner = std::thread::spawn(move || control::live_owner(&data_dir)); + std::thread::sleep(Duration::from_millis(300)); + assert!(path.exists(), "the stale file was removed under another process's claim lock"); + + lock.unlock().unwrap(); + assert!(cleaner.join().unwrap().is_none()); + assert!(!path.exists()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn start_is_refused_while_another_process_owns_the_engine() { + let dir = tempfile::tempdir().unwrap(); + let client_dir = tempfile::tempdir().unwrap(); + let ctx = ctx(dir.path(), client_dir.path()); + + // A live CLI daemon: a running process, and a control port that answers. + let mut daemon = if cfg!(windows) { + std::process::Command::new("ping").args(["-n", "30", "127.0.0.1"]).spawn().unwrap() + } else { + std::process::Command::new("sleep").arg("30").spawn().unwrap() + }; + let control_port = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let owner = ControlInfo { + pid: daemon.id(), + control_port: control_port.local_addr().unwrap().port(), + proxy_port: Some(9090), + token: "daemon".into(), + owner: Owner::Cli, + started_at: chrono::Utc::now(), + }; + std::fs::write(dir.path().join("control.json"), serde_json::to_vec(&owner).unwrap()).unwrap(); + + let err = ctx.start().unwrap_err(); + assert!(err.contains("already running in the yaak CLI"), "{err}"); + assert_eq!(ctx.port(), None); + + daemon.kill().unwrap(); + daemon.wait().unwrap(); +} + +async fn rpc(ctx: &ProxyCtx, cmd: &str, payload: Value) -> Result { + yaak_proxy_lib::build_router().dispatch(cmd, payload, ctx).await.map_err(|e| e.message) +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_app_takes_over_from_the_cli() { + let dir = tempfile::tempdir().unwrap(); + let client_dir = tempfile::tempdir().unwrap(); + let cli = ctx(dir.path(), client_dir.path()).with_owner(Owner::Cli); + let mut server = ControlServer::start(cli.clone()).unwrap(); + let stop_rx = server.take_stop_rx().unwrap(); + // The daemon exits as soon as it hears the stop. + let daemon = std::thread::spawn(move || { + stop_rx.recv().unwrap(); + drop(server); + }); + cli.start().unwrap(); + let cli_port = cli.port().unwrap(); + + // The app opens onto the CLI's engine and cannot start its own. + let app = ctx(dir.path(), client_dir.path()); + assert!(app.serve_control_api().unwrap_err().contains("already running in the yaak CLI")); + let state = rpc(&app, "get_proxy_state", json!({})).await.unwrap(); + assert_eq!(state["state"], "stopped"); + assert_eq!(state["otherOwner"]["owner"], "cli"); + assert_eq!(state["otherOwner"]["proxyPort"], cli_port); + assert_eq!(state["otherOwner"]["pid"], std::process::id()); + + assert_eq!(rpc(&app, "take_over", json!({})).await.unwrap(), json!(true)); + daemon.join().unwrap(); + assert_eq!(cli.port(), None); + assert!(app.port().is_some()); + let owner = control::read(dir.path()).unwrap(); + assert_eq!((owner.owner, owner.proxy_port), (Owner::App, app.port())); + let state = rpc(&app, "get_proxy_state", json!({})).await.unwrap(); + assert_eq!((state["state"].as_str(), &state["otherOwner"]), (Some("running"), &Value::Null)); + + // Taking over with no one else holding the engine only starts it. + assert_eq!(rpc(&app, "take_over", json!({})).await.unwrap(), json!(true)); + app.stop().unwrap(); + app.stop_control_api(); + assert!(control::live_owner(dir.path()).is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_save_dialog_reads_workspaces_previews_and_saves() { + let dir = tempfile::tempdir().unwrap(); + let client_dir = tempfile::tempdir().unwrap(); + let ctx = ctx(dir.path(), client_dir.path()); + ctx.start().unwrap(); + let flow = capture_flow(&ctx).await; + + // Before Yaak has ever run there is nothing to save into, and the dialog says so. + let listed = rpc(&ctx, "list_workspaces", json!({})).await.unwrap(); + assert_eq!(listed, json!({ "yaakFound": false, "workspaces": [] })); + + let (query_manager, _blobs, _rx) = yaak_models::init_standalone( + client_dir.path().join("db.sqlite"), + client_dir.path().join("blobs.sqlite"), + ) + .unwrap(); + let (workspace, folder) = query_manager + .with_tx(|tx| { + let workspace = tx.upsert_workspace( + &Workspace { name: "Payments".into(), ..Default::default() }, + &UpdateSource::Background, + )?; + let folder = tx.upsert_folder( + &Folder { + name: "Stripe".into(), + workspace_id: workspace.id.clone(), + ..Default::default() + }, + &UpdateSource::Background, + )?; + Ok::<_, yaak_models::error::Error>((workspace, folder)) + }) + .unwrap(); + + let listed = rpc(&ctx, "list_workspaces", json!({})).await.unwrap(); + assert_eq!(listed["yaakFound"], true); + assert_eq!(listed["workspaces"][0]["name"], "Payments"); + assert_eq!(listed["workspaces"][0]["folders"][0]["id"], folder.id.as_str()); + + let preview = rpc(&ctx, "preview_save", json!({ "flowId": flow.id })).await.unwrap(); + assert_eq!(preview["method"], "POST"); + assert!(preview["requestUrl"].as_str().unwrap().ends_with("/v1/charges"), "{preview}"); + assert_eq!(preview["bodyType"], "application/json"); + assert_eq!(preview["body"], "JSON"); + assert_eq!(preview["authorization"], true); + let dropped: Vec<&str> = preview["droppedHeaders"] + .as_array() + .unwrap() + .iter() + .map(|h| h["name"].as_str().unwrap()) + .collect(); + assert!(dropped.iter().any(|h| h.eq_ignore_ascii_case("host")), "{dropped:?}"); + assert!(dropped.iter().any(|h| h.eq_ignore_ascii_case("content-length")), "{dropped:?}"); + assert!(!dropped.iter().any(|h| h.eq_ignore_ascii_case("authorization")), "{dropped:?}"); + assert_eq!( + preview["keptHeaders"].as_u64().unwrap() as usize + dropped.len(), + flow.request_headers.len() + ); + assert!(preview["warnings"][0].as_str().unwrap().contains("Authorization")); + + let saved = rpc( + &ctx, + "save_flow", + json!({ + "flowId": flow.id, + "workspaceId": workspace.id, + "folderId": folder.id, + "name": "Create charge", + }), + ) + .await + .unwrap(); + assert_eq!(saved["name"], "Create charge"); + let request_id = saved["requestId"].as_str().unwrap(); + let request = query_manager.connect().get_http_request(request_id).unwrap(); + assert_eq!( + (request.workspace_id.as_str(), request.folder_id.as_deref()), + (workspace.id.as_str(), Some(folder.id.as_str())) + ); + let stored = ctx + .db + .with_conn(|db| db.find_one::(yaak_proxy_lib::models::FlowIden::Id, &flow.id)) + .unwrap(); + assert_eq!(stored.saved_request_id.as_deref(), Some(request_id)); + + // A workspace that is gone is an error the dialog can show. + let err = rpc(&ctx, "save_flow", json!({ "flowId": flow.id, "workspaceId": "wk_gone" })) + .await + .unwrap_err(); + assert!(err.contains("No workspace wk_gone"), "{err}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn export_har_writes_the_flows_asked_for() { + let dir = tempfile::tempdir().unwrap(); + let client_dir = tempfile::tempdir().unwrap(); + let ctx = ctx(dir.path(), client_dir.path()); + ctx.start().unwrap(); + let first = capture_flow(&ctx).await; + let second = capture_flow(&ctx).await; + let read = + |path: &Path| -> Value { serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap() }; + + let selected = dir.path().join("selected.har"); + let written = + rpc(&ctx, "export_har", json!({ "path": selected, "ids": [first.id] })).await.unwrap(); + assert_eq!(written, 1); + assert_eq!(read(&selected)["log"]["entries"].as_array().unwrap().len(), 1); + + let searched = dir.path().join("searched.har"); + let filter = json!({ "q": "charges", "limit": 1 }); + let written = + rpc(&ctx, "export_har", json!({ "path": searched, "filter": filter })).await.unwrap(); + // The whole search, not the page the list had loaded. + assert_eq!(written, 2); + let entries = read(&searched)["log"]["entries"].as_array().unwrap().len(); + assert_eq!(entries, 2); + assert_ne!(first.id, second.id); + + let none = dir.path().join("none.har"); + let written = + rpc(&ctx, "export_har", json!({ "path": none, "filter": { "q": "nothing-matches" } })) + .await + .unwrap(); + assert_eq!(written, 0); +} + +/// curl, through the engine, shows as the app behind its flow. +#[cfg(target_os = "macos")] +#[tokio::test(flavor = "multi_thread")] +async fn flows_name_the_app_that_sent_them() { + let dir = tempfile::tempdir().unwrap(); + let client_dir = tempfile::tempdir().unwrap(); + let ctx = ctx(dir.path(), client_dir.path()); + ctx.start().unwrap(); + let upstream = spawn_upstream().await; + let url = format!("http://{upstream}/attributed"); + + let mut curl = std::process::Command::new("/usr/bin/curl") + .args(["-s", "-o", "/dev/null", "--max-time", "10", "--noproxy", ""]) + .args([ + "-x", + &format!("http://127.0.0.1:{}", ctx.port().unwrap()), + &url, + ]) + .spawn() + .unwrap(); + let curl_pid = curl.id() as i32; + assert!(curl.wait().unwrap().success()); + + let deadline = Instant::now() + Duration::from_secs(5); + let flow = loop { + let flows = ctx.db.with_conn(|db| db.find_all::().unwrap()); + if let Some(flow) = flows.into_iter().find(|f| f.url == url) { + break flow; + } + assert!(Instant::now() < deadline, "no flow for {url}"); + tokio::time::sleep(Duration::from_millis(20)).await; + }; + assert_eq!(flow.client_name.as_deref(), Some("curl")); + assert_eq!(flow.client_pid, Some(curl_pid)); + assert_eq!(flow.client_bundle_id, None); + assert_eq!(flow.client_path.as_deref(), Some("/usr/bin/curl")); + + // The search and the sidebar's Apps group find it by its path. + let filter = FlowFilter { app: Some(vec!["/usr/bin/curl".into()]), ..Default::default() }; + assert_eq!(yaak_proxy_lib::flows::list(&ctx.db, &filter).unwrap().len(), 1); +} + +/// Sends one GET through the engine, without waiting for a flow. +async fn send_through(ctx: &ProxyCtx, path: &str) -> String { + let upstream = spawn_upstream().await; + let url = format!("http://{upstream}{path}"); + let client = reqwest::Client::builder() + .proxy(reqwest::Proxy::http(format!("http://127.0.0.1:{}", ctx.port().unwrap())).unwrap()) + .build() + .unwrap(); + let resp = client.get(&url).send().await.unwrap(); + assert_eq!(resp.status(), 200, "traffic passes through either way"); + url +} + +#[tokio::test(flavor = "multi_thread")] +async fn pausing_stores_no_flows_and_resuming_stores_them_again() { + let dir = tempfile::tempdir().unwrap(); + let client_dir = tempfile::tempdir().unwrap(); + let ctx = ctx(dir.path(), client_dir.path()); + let _server = ControlServer::start(ctx.clone()).unwrap(); + ctx.start().unwrap(); + let client = ControlClient::new(control::read(dir.path()).unwrap()); + assert!(client.status().await.unwrap().recording); + + // Paused from the app. + assert_eq!(rpc(&ctx, "set_recording", json!({ "enabled": false })).await.unwrap(), false); + let state = rpc(&ctx, "get_proxy_state", json!({})).await.unwrap(); + assert_eq!((state["state"].as_str(), &state["recording"]), (Some("running"), &json!(false))); + let paused = send_through(&ctx, "/while-paused").await; + tokio::time::sleep(Duration::from_millis(300)).await; + let flows = ctx.db.with_conn(|db| db.find_all::().unwrap()); + assert!(flows.iter().all(|f| f.url != paused), "a flow was stored while paused"); + assert!(ctx.blobs.connect().list_body_ids().unwrap().is_empty(), "a body was stored"); + + // Resumed through the control API, as the CLI would. + let status = client.set_recording(true).await.unwrap(); + assert!(status.recording); + assert!(ctx.recording()); + let flow = capture_flow(&ctx).await; + assert_eq!(flow.method, "POST"); + // And the hotkey's toggle pauses again. + rpc(&ctx, "execute_action", json!({ "scope": "global", "action": "recording_toggle" })) + .await + .unwrap(); + assert!(!client.status().await.unwrap().recording); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_failed_start_is_kept_for_the_app_until_the_engine_starts() { + let dir = tempfile::tempdir().unwrap(); + let client_dir = tempfile::tempdir().unwrap(); + let ctx = ctx(dir.path(), client_dir.path()); + // Something else has the port. + let taken = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = taken.local_addr().unwrap().port(); + let settings = ProxySettings { port, system_proxy_at_launch: false, ..Default::default() }; + settings.save(&dir.path().join("settings.json")).unwrap(); + + assert!(ctx.start().is_err()); + let state = rpc(&ctx, "get_proxy_state", json!({})).await.unwrap(); + assert_eq!(state["state"], "stopped"); + assert!(state["startError"].as_str().unwrap().contains(&port.to_string()), "{state}"); + + drop(taken); + ctx.start().unwrap(); + let state = rpc(&ctx, "get_proxy_state", json!({})).await.unwrap(); + assert_eq!((state["state"].as_str(), &state["startError"]), (Some("running"), &Value::Null)); + ctx.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_system_proxy_at_launch_setting_is_saved_and_routing_needs_an_engine() { + let dir = tempfile::tempdir().unwrap(); + let client_dir = tempfile::tempdir().unwrap(); + let ctx = ctx(dir.path(), client_dir.path()); + + // Defaults on, like Proxyman. + assert!(ProxySettings::default().system_proxy_at_launch); + for enabled in [true, false] { + rpc(&ctx, "set_system_proxy_at_launch", json!({ "enabled": enabled })).await.unwrap(); + assert_eq!(ctx.settings().unwrap().system_proxy_at_launch, enabled); + let state = rpc(&ctx, "get_proxy_state", json!({})).await.unwrap(); + assert_eq!(state["systemProxyAtLaunch"], enabled); + } + + // With no engine there is nothing to point the Mac at. + let err = rpc(&ctx, "system_proxy_set", json!({ "enabled": true })).await.unwrap_err(); + assert!(err.contains("not running"), "{err}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn another_app_owning_the_engine_is_not_taken_over() { + let dir = tempfile::tempdir().unwrap(); + let client_dir = tempfile::tempdir().unwrap(); + // Another app instance, whose engine a stop would halt without releasing its claim. + let other = ctx(dir.path(), client_dir.path()); + let _server = ControlServer::start(other.clone()).unwrap(); + other.start().unwrap(); + + let app = ctx(dir.path(), client_dir.path()); + let state = rpc(&app, "get_proxy_state", json!({})).await.unwrap(); + assert_eq!(state["otherOwner"]["owner"], "app"); + let err = rpc(&app, "take_over", json!({})).await.unwrap_err(); + assert_eq!(err, yaak_proxy_lib::ANOTHER_APP_OWNS); + // Refused before touching it: the other app keeps running. + assert!(other.port().is_some()); + assert_eq!(app.port(), None); + other.stop().unwrap(); +} diff --git a/crates-proxy/yaak-proxy-lib/tests/fixtures/export.har b/crates-proxy/yaak-proxy-lib/tests/fixtures/export.har new file mode 100644 index 000000000..bf3234b64 --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/tests/fixtures/export.har @@ -0,0 +1,241 @@ +{ + "log": { + "version": "1.2", + "creator": { + "name": "Yaak Proxy", + "version": "test" + }, + "entries": [ + { + "startedDateTime": "2026-10-26T09:30:01.250Z", + "time": 134, + "request": { + "method": "GET", + "url": "https://api.github.com/repos/mountain-loop/yaak/issues?state=open&labels=bug%20fix", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Host", + "value": "api.github.com" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + } + ], + "queryString": [ + { + "name": "state", + "value": "open" + }, + { + "name": "labels", + "value": "bug fix" + } + ], + "headersSize": -1, + "bodySize": 0 + }, + "response": { + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/2", + "cookies": [], + "headers": [ + { + "name": "content-type", + "value": "application/json; charset=utf-8" + }, + { + "name": "content-encoding", + "value": "gzip" + } + ], + "content": { + "size": 30, + "mimeType": "application/json; charset=utf-8", + "text": "[{\"number\":761,\"title\":\"MCP\"}]" + }, + "redirectURL": "", + "headersSize": -1, + "bodySize": 41 + }, + "cache": {}, + "timings": { + "send": 0, + "wait": 120, + "receive": 14 + }, + "serverIPAddress": "140.82.112.6" + }, + { + "startedDateTime": "2026-10-26T09:30:02.250Z", + "time": 8, + "request": { + "method": "POST", + "url": "http://localhost:3000/login", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Content-Type", + "value": "application/x-www-form-urlencoded" + } + ], + "queryString": [], + "postData": { + "mimeType": "application/x-www-form-urlencoded", + "text": "user=greg&password=hunter2" + }, + "headersSize": -1, + "bodySize": 26 + }, + "response": { + "status": 302, + "statusText": "Found", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Location", + "value": "/dashboard" + } + ], + "content": { + "size": 0, + "mimeType": "" + }, + "redirectURL": "/dashboard", + "headersSize": -1, + "bodySize": 0 + }, + "cache": {}, + "timings": { + "send": 0, + "wait": 8, + "receive": 0 + }, + "serverIPAddress": "::1" + }, + { + "startedDateTime": "2026-10-26T09:30:03.250Z", + "time": 31, + "request": { + "method": "GET", + "url": "https://images.example.com/logo.png", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [], + "queryString": [], + "headersSize": -1, + "bodySize": 0 + }, + "response": { + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/2", + "cookies": [], + "headers": [ + { + "name": "Content-Type", + "value": "image/png" + } + ], + "content": { + "size": 8, + "mimeType": "image/png", + "text": "iVBORw0KGgo=", + "encoding": "base64" + }, + "redirectURL": "", + "headersSize": -1, + "bodySize": 8 + }, + "cache": {}, + "timings": { + "send": 0, + "wait": 30, + "receive": 1 + } + }, + { + "startedDateTime": "2026-10-26T09:30:04.250Z", + "time": 900, + "request": { + "method": "GET", + "url": "https://cdn.example.com/big.txt", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [], + "queryString": [], + "headersSize": -1, + "bodySize": 0 + }, + "response": { + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Content-Type", + "value": "text/plain" + } + ], + "content": { + "size": 10, + "mimeType": "text/plain", + "text": "0123456789", + "_truncated": true + }, + "redirectURL": "", + "headersSize": -1, + "bodySize": 20000000 + }, + "cache": {}, + "timings": { + "send": 0, + "wait": 10, + "receive": 890 + }, + "_error": "connection reset by peer" + }, + { + "startedDateTime": "2026-10-26T09:30:05.250Z", + "time": 3, + "request": { + "method": "GET", + "url": "https://down.example.com/", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [], + "queryString": [], + "headersSize": -1, + "bodySize": 0 + }, + "response": { + "status": 0, + "statusText": "", + "httpVersion": "", + "cookies": [], + "headers": [], + "content": { + "size": 0, + "mimeType": "" + }, + "redirectURL": "", + "headersSize": -1, + "bodySize": -1 + }, + "cache": {}, + "timings": { + "send": 0, + "wait": 3, + "receive": 0 + }, + "_error": "Connection refused (os error 61)" + } + ] + } +} diff --git a/crates-proxy/yaak-proxy-lib/tests/flow.rs b/crates-proxy/yaak-proxy-lib/tests/flow.rs new file mode 100644 index 000000000..2c5c2cc09 --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/tests/flow.rs @@ -0,0 +1,393 @@ +use std::convert::Infallible; +use std::io::Write; +use std::net::SocketAddr; +use std::time::{Duration, Instant}; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use bytes::Bytes; +use flate2::Compression; +use flate2::write::GzEncoder; +use http_body_util::{BodyExt, Full}; +use hyper::body::Incoming; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::{Request, Response}; +use hyper_util::rt::TokioIo; +use serde_json::{Value, json}; +use tokio::net::TcpListener; +use yaak_proxy_lib::ProxyCtx; +use yaak_proxy_lib::bodies::{DecodedBody, read_body, read_raw_body}; +use yaak_proxy_lib::models::{Flow, FlowBody}; +use yaak_proxy_lib::settings::ProxySettings; +use yaak_rpc::RpcEventEmitter; + +const CAPTURE_LIMIT: usize = 1024; +const BIG_BODY: usize = 4096; + +fn gzip(data: &[u8]) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(data).unwrap(); + encoder.finish().unwrap() +} + +async fn route(req: Request) -> Result>, Infallible> { + Ok(match req.uri().path() { + "/echo" => { + let body = req.into_body().collect().await.unwrap().to_bytes(); + Response::builder() + .header("content-type", "text/plain") + .header("content-encoding", "gzip") + .body(Full::from(gzip(&body))) + .unwrap() + } + // Answers at once and drains the upload afterwards, so the response ends first. + "/early" => { + let mut body = req.into_body(); + tokio::spawn(async move { + while let Some(Ok(_)) = body.frame().await { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }); + Response::new(Full::from("ok")) + } + // Small on the wire, past the capture limit once decoded. + "/expands" => Response::builder() + .header("content-type", "text/plain") + .header("content-encoding", "gzip") + .body(Full::from(gzip(&vec![b'z'; BIG_BODY]))) + .unwrap(), + "/json" => Response::builder() + .header("content-type", "application/json") + .body(Full::from(r#"{"items":[{"id":1},{"id":2}]}"#)) + .unwrap(), + // Names an encoding but, like any 304, has no body + "/not-modified" => Response::builder() + .status(304) + .header("content-encoding", "gzip") + .body(Full::new(Bytes::new())) + .unwrap(), + "/hang" => std::future::pending().await, + // XML that only its body says is XML + "/xml-as-text" => Response::builder() + .header("content-type", "text/plain") + .body(Full::from("12")) + .unwrap(), + _ => Response::new(Full::from(vec![b'x'; BIG_BODY])), + }) +} + +async fn spawn_upstream() -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let (tcp, _) = listener.accept().await.unwrap(); + tokio::spawn( + http1::Builder::new().serve_connection(TokioIo::new(tcp), service_fn(route)), + ); + } + }); + addr +} + +/// Waits for the event loop to write the flow for `url`. +async fn wait_for_flow(ctx: &ProxyCtx, url: &str) -> Flow { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let flows = ctx.db.with_conn(|db| db.find_all::().unwrap()); + if let Some(flow) = flows.into_iter().find(|f| f.url == url) { + return flow; + } + assert!(Instant::now() < deadline, "no flow for {url}"); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn flow_is_stored_with_its_bodies() { + let dir = tempfile::tempdir().unwrap(); + let settings = ProxySettings { + port: 0, + capture_body_limit: CAPTURE_LIMIT, + system_proxy_at_launch: false, + ..Default::default() + }; + settings.save(&dir.path().join("settings.json")).unwrap(); + + let (events, _event_rx) = RpcEventEmitter::new(); + let ctx = ProxyCtx::new(dir.path(), events).unwrap(); + ctx.start().unwrap(); + let port = ctx.port().unwrap(); + assert!(dir.path().join("ca/ca.pem").exists()); + assert!(dir.path().join("blobs.sqlite").exists()); + + let upstream = spawn_upstream().await; + let client = reqwest::Client::builder() + .proxy(reqwest::Proxy::all(format!("http://127.0.0.1:{port}")).unwrap()) + .build() + .unwrap(); + + let echo_url = format!("http://{upstream}/echo"); + let resp = client.post(&echo_url).body("hello proxy").send().await.unwrap(); + assert_eq!(resp.status(), 200); + resp.bytes().await.unwrap(); + + let flow = wait_for_flow(&ctx, &echo_url).await; + let compressed = gzip(b"hello proxy"); + assert!(flow.id.starts_with("fl_"), "{}", flow.id); + assert_eq!(flow.method, "POST"); + assert_eq!(flow.status, Some(200)); + assert_eq!(flow.client_http_version, "HTTP/1.1"); + assert_eq!(flow.upstream_http_version.as_deref(), Some("HTTP/1.1")); + assert_eq!(flow.remote_addr, Some(upstream.to_string())); + assert_eq!(flow.content_type.as_deref(), Some("text/plain")); + assert_eq!(flow.request_size, 11); + assert_eq!(flow.response_size, compressed.len() as i64); + assert!(!flow.request_truncated && !flow.response_truncated); + assert!(flow.elapsed_headers_ms.is_some() && flow.elapsed_ms >= flow.elapsed_headers_ms); + assert!(flow.request_headers.iter().any(|h| h.name == "content-length")); + assert_eq!(flow.error, None); + + assert_eq!(read_raw_body(&ctx.blobs, &flow.id, FlowBody::Request).unwrap(), b"hello proxy"); + assert_eq!(read_raw_body(&ctx.blobs, &flow.id, FlowBody::Response).unwrap(), compressed); + let decoded = read_body(&ctx.blobs, &flow, FlowBody::Response, CAPTURE_LIMIT).await.unwrap(); + let expected = DecodedBody { + data: b"hello proxy".to_vec(), + stored_size: compressed.len(), + truncated: false, + decode_error: None, + }; + assert_eq!(decoded, expected); + + let big_url = format!("http://{upstream}/big"); + let resp = client.get(&big_url).send().await.unwrap(); + assert_eq!(resp.bytes().await.unwrap().len(), BIG_BODY); + + let flow = wait_for_flow(&ctx, &big_url).await; + assert_eq!(flow.request_size, 0); + assert_eq!(flow.response_size, BIG_BODY as i64); + assert!(flow.response_truncated); + let stored = read_raw_body(&ctx.blobs, &flow.id, FlowBody::Response).unwrap(); + assert_eq!(stored, vec![b'x'; CAPTURE_LIMIT]); + assert!(!ctx.blobs.connect().body_exists(&Flow::body_id(&flow.id, FlowBody::Request)).unwrap()); + + ctx.stop().unwrap(); + assert_eq!(ctx.port(), None); +} + +#[tokio::test(flavor = "multi_thread")] +async fn early_response_keeps_request_totals() { + let dir = tempfile::tempdir().unwrap(); + let settings = ProxySettings { + port: 0, + capture_body_limit: CAPTURE_LIMIT, + system_proxy_at_launch: false, + ..Default::default() + }; + settings.save(&dir.path().join("settings.json")).unwrap(); + + let (events, _event_rx) = RpcEventEmitter::new(); + let ctx = ProxyCtx::new(dir.path(), events).unwrap(); + ctx.start().unwrap(); + let port = ctx.port().unwrap(); + + let upstream = spawn_upstream().await; + let client = reqwest::Client::builder() + .proxy(reqwest::Proxy::all(format!("http://127.0.0.1:{port}")).unwrap()) + .build() + .unwrap(); + + let url = format!("http://{upstream}/early"); + let _ = client.post(&url).body(vec![b'u'; 2 * 1024 * 1024]).send().await; + + let flow = wait_for_flow(&ctx, &url).await; + assert_eq!(flow.status, Some(200)); + let stored = read_raw_body(&ctx.blobs, &flow.id, FlowBody::Request).unwrap().len() as i64; + assert!(stored > 0); + assert!(flow.request_size >= stored, "{} < {stored}", flow.request_size); + assert_eq!(flow.request_truncated, flow.request_size > stored); +} + +async fn rpc(ctx: &ProxyCtx, cmd: &str, payload: Value) -> Result { + yaak_proxy_lib::build_router().dispatch(cmd, payload, ctx).await.map_err(|e| e.message) +} + +/// A request the client gives up on is stored as cancelled, not as an error. +#[tokio::test(flavor = "multi_thread")] +async fn cancelled_flow_is_not_an_error() { + let dir = tempfile::tempdir().unwrap(); + let settings = ProxySettings { port: 0, system_proxy_at_launch: false, ..Default::default() }; + settings.save(&dir.path().join("settings.json")).unwrap(); + + let (events, _event_rx) = RpcEventEmitter::new(); + let ctx = ProxyCtx::new(dir.path(), events).unwrap(); + ctx.start().unwrap(); + let port = ctx.port().unwrap(); + + let upstream = spawn_upstream().await; + let client = reqwest::Client::builder() + .proxy(reqwest::Proxy::all(format!("http://127.0.0.1:{port}")).unwrap()) + .timeout(Duration::from_millis(300)) + .build() + .unwrap(); + let url = format!("http://{upstream}/hang"); + assert!(client.get(&url).send().await.unwrap_err().is_timeout()); + + let flow = wait_for_flow(&ctx, &url).await; + assert!(flow.cancelled); + assert_eq!((flow.error, flow.status), (None, None)); + assert!(flow.elapsed_ms.is_some()); +} + +/// `http1Only` is saved and reaches the running engine: the next connection from a client that +/// offers HTTP/2 speaks HTTP/1.1, and `auto` brings HTTP/2 back. +#[tokio::test(flavor = "multi_thread")] +async fn http_version_setting_applies_to_new_connections() { + let dir = tempfile::tempdir().unwrap(); + let settings = ProxySettings { port: 0, system_proxy_at_launch: false, ..Default::default() }; + settings.save(&dir.path().join("settings.json")).unwrap(); + + let (events, _event_rx) = RpcEventEmitter::new(); + let ctx = ProxyCtx::new(dir.path(), events).unwrap(); + ctx.start().unwrap(); + let port = ctx.port().unwrap(); + let ca = std::fs::read(dir.path().join("ca/ca.pem")).unwrap(); + // Nothing listens there, so each flow is a 502, but its client side is spoken all the same. + let closed = TcpListener::bind("127.0.0.1:0").await.unwrap().local_addr().unwrap().port(); + + let mut sent = 0; + let mut client_version = async || { + sent += 1; + // A client of its own, so a connection of its own. + let client = reqwest::Client::builder() + .proxy(reqwest::Proxy::all(format!("http://127.0.0.1:{port}")).unwrap()) + .add_root_certificate(reqwest::Certificate::from_pem(&ca).unwrap()) + .build() + .unwrap(); + let url = format!("https://localhost:{closed}/{sent}"); + assert_eq!(client.get(&url).send().await.unwrap().status(), 502); + wait_for_flow(&ctx, &url).await.client_http_version + }; + + assert_eq!(client_version().await, "HTTP/2"); + let set = rpc(&ctx, "set_http_version", json!({"httpVersion": "http1Only"})).await.unwrap(); + assert_eq!(set, json!("http1Only")); + assert_eq!(rpc(&ctx, "get_proxy_state", json!({})).await.unwrap()["httpVersion"], "http1Only"); + assert_eq!(client_version().await, "HTTP/1.1"); + + rpc(&ctx, "set_http_version", json!({"httpVersion": "auto"})).await.unwrap(); + assert_eq!(client_version().await, "HTTP/2"); + let saved = ProxySettings::load(&dir.path().join("settings.json")).unwrap(); + assert_eq!(saved.http_version, yaak_proxy_lib::settings::HttpVersion::Auto); +} + +#[tokio::test(flavor = "multi_thread")] +async fn body_rpcs() { + let dir = tempfile::tempdir().unwrap(); + let settings = ProxySettings { + port: 0, + capture_body_limit: CAPTURE_LIMIT, + system_proxy_at_launch: false, + ..Default::default() + }; + settings.save(&dir.path().join("settings.json")).unwrap(); + + let (events, _event_rx) = RpcEventEmitter::new(); + let ctx = ProxyCtx::new(dir.path(), events).unwrap(); + ctx.start().unwrap(); + let port = ctx.port().unwrap(); + + let state = rpc(&ctx, "get_proxy_state", json!({})).await.unwrap(); + assert_eq!( + state, + json!({ + "state": "running", + "port": port, + "configuredPort": 0, + "recording": true, + "systemProxyAtLaunch": false, + "httpVersion": "auto", + "startError": null, + "otherOwner": null, + }) + ); + + let upstream = spawn_upstream().await; + let client = reqwest::Client::builder() + .proxy(reqwest::Proxy::all(format!("http://127.0.0.1:{port}")).unwrap()) + .build() + .unwrap(); + + let expands_url = format!("http://{upstream}/expands"); + client.get(&expands_url).send().await.unwrap().bytes().await.unwrap(); + let flow = wait_for_flow(&ctx, &expands_url).await; + assert!(!flow.response_truncated); + let body = + rpc(&ctx, "get_flow_body", json!({"flowId": flow.id, "which": "response"})).await.unwrap(); + assert_eq!(body["truncated"], json!(true)); + assert!(body["storedSize"].as_u64().unwrap() < CAPTURE_LIMIT as u64); + let data = BASE64.decode(body["data"].as_str().unwrap()).unwrap(); + assert_eq!(data, vec![b'z'; CAPTURE_LIMIT]); + + let empty = + rpc(&ctx, "get_flow_body", json!({"flowId": flow.id, "which": "request"})).await.unwrap(); + assert_eq!(empty, json!({"data": "", "truncated": false, "storedSize": 0})); + + let not_modified_url = format!("http://{upstream}/not-modified"); + client.get(¬_modified_url).send().await.unwrap().bytes().await.unwrap(); + let flow = wait_for_flow(&ctx, ¬_modified_url).await; + let body = + rpc(&ctx, "get_flow_body", json!({"flowId": flow.id, "which": "response"})).await.unwrap(); + assert_eq!(body, json!({"data": "", "truncated": false, "storedSize": 0})); + + let json_url = format!("http://{upstream}/json"); + client.get(&json_url).send().await.unwrap().bytes().await.unwrap(); + let flow = wait_for_flow(&ctx, &json_url).await; + let filtered = rpc( + &ctx, + "filter_body", + json!({"flowId": flow.id, "which": "response", "expression": "$.items[*].id"}), + ) + .await + .unwrap(); + assert_eq!(filtered, json!({"content": "[\n 1,\n 2\n]", "error": null})); + let invalid = rpc( + &ctx, + "filter_body", + json!({"flowId": flow.id, "which": "response", "expression": "$.items[?(@.id ==)]"}), + ) + .await + .unwrap(); + assert_eq!(invalid["content"], json!("")); + assert!(invalid["error"].is_string()); + + let xml_url = format!("http://{upstream}/xml-as-text"); + client.get(&xml_url).send().await.unwrap().bytes().await.unwrap(); + let flow = wait_for_flow(&ctx, &xml_url).await; + let xpath = json!({ + "flowId": flow.id, + "which": "response", + "expression": "//id[2]", + "language": "xpath", + }); + let filtered = rpc(&ctx, "filter_body", xpath).await.unwrap(); + assert_eq!(filtered, json!({"content": "2", "error": null})); + // Without the viewer's language, `text/plain` means JSONPath, which can't read XML + let by_header = rpc( + &ctx, + "filter_body", + json!({"flowId": flow.id, "which": "response", "expression": "//id[2]"}), + ) + .await + .unwrap(); + assert_eq!(by_header["content"], json!("")); + assert!(by_header["error"].is_string()); + + let missing = + rpc(&ctx, "get_flow_body", json!({"flowId": "fl_missing", "which": "response"})).await; + assert!(missing.is_err()); + + let formatted = rpc(&ctx, "format_body", json!({"text": r#"{"a":[1,2]}"#})).await.unwrap(); + assert_eq!(formatted, json!("{\n \"a\": [\n 1,\n 2\n ]\n}")); +} diff --git a/crates-server/yaak-playground/Cargo.toml b/crates-server/yaak-playground/Cargo.toml new file mode 100644 index 000000000..8427219f8 --- /dev/null +++ b/crates-server/yaak-playground/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "yaak-playground" +version = "0.1.0" +edition = "2024" +publish = false +description = "A small, disposable API for trying Yaak against (yaak.run)" + +[[bin]] +name = "yaak-playground" +path = "src/main.rs" + +[dependencies] +axum = "0.7" +bytes = "1.11.1" +clap = { version = "4.5", features = ["derive", "env"] } +env_logger = "0.11" +log = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "net"] } +tower-http = { version = "0.7", features = ["cors"] } +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } diff --git a/crates-server/yaak-playground/README.md b/crates-server/yaak-playground/README.md new file mode 100644 index 000000000..bdcb8ef48 --- /dev/null +++ b/crates-server/yaak-playground/README.md @@ -0,0 +1,21 @@ +# yaak-playground + +The API behind [yaak.run](https://yaak.run), for trying Yaak without bringing your own. + +- Users and todos are fixed sample data. +- Posts can be created, changed, and deleted. Each client IP gets its own copy, which goes back + to the sample data `YAAK_PLAYGROUND_RESET_AFTER_SECS` after its first write. +- `POST /auth/token` returns a bearer token for `GET /auth/me`. `POST /auth/session` sets a + session cookie for `GET /auth/session`. Any username with the password `yaak` logs in. +- `GET /` lists every endpoint and `GET /openapi.json` describes them. + +Nothing is written to disk, so a restart resets everything. + +```sh +cargo run -p yaak-playground # http://127.0.0.1:9228 +``` + +Run `yaak-playground --help` for the limits. Behind a load balancer that sets +`X-Forwarded-For`, set `YAAK_PLAYGROUND_TRUST_FORWARDED_FOR=true`. + +The image is `Dockerfile.playground` at the repo root. diff --git a/crates-server/yaak-playground/src/auth.rs b/crates-server/yaak-playground/src/auth.rs new file mode 100644 index 000000000..7eacea4f2 --- /dev/null +++ b/crates-server/yaak-playground/src/auth.rs @@ -0,0 +1,185 @@ +use crate::AppState; +use crate::data::{self, User}; +use crate::error::{ApiError, ApiResult, parse_json}; +use axum::extract::State; +use axum::extract::rejection::BytesRejection; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Json, Response}; +use bytes::Bytes; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +pub const PASSWORD: &str = "yaak"; +const SESSION_COOKIE: &str = "session"; +const TTL: Duration = Duration::from_secs(60 * 60); +const MAX_CREDENTIALS: usize = 10_000; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Kind { + Bearer, + Session, +} + +/// Issued tokens and session IDs, kept apart so a token can't be sent as a cookie or the +/// other way around. +pub struct Credentials { + entries: Mutex>, +} + +struct Entry { + user_id: u64, + kind: Kind, + expires: Instant, +} + +impl Credentials { + pub fn new() -> Self { + Self { entries: Mutex::new(HashMap::new()) } + } + + fn issue(&self, user_id: u64, kind: Kind) -> String { + let value = uuid::Uuid::new_v4().simple().to_string(); + let now = Instant::now(); + let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner()); + if entries.len() >= MAX_CREDENTIALS { + entries.retain(|_, e| e.expires > now); + } + if entries.len() >= MAX_CREDENTIALS + && let Some(oldest) = + entries.iter().min_by_key(|(_, e)| e.expires).map(|(k, _)| k.clone()) + { + entries.remove(&oldest); + } + entries.insert(value.clone(), Entry { user_id, kind, expires: now + TTL }); + value + } + + fn lookup(&self, value: &str, kind: Kind) -> Option<&'static User> { + let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner()); + let entry = entries.get(value)?; + if entry.kind != kind || entry.expires <= Instant::now() { + return None; + } + data::user(entry.user_id) + } + + fn revoke(&self, value: &str) { + self.entries.lock().unwrap_or_else(|e| e.into_inner()).remove(value); + } +} + +#[derive(Deserialize)] +struct Login { + username: String, + password: String, +} + +fn log_in(body: Result) -> ApiResult<&'static User> { + let login: Login = parse_json(body)?; + data::user_by_username(&login.username) + .filter(|_| login.password == PASSWORD) + .ok_or_else(|| ApiError::unauthorized("Invalid username or password", None)) +} + +pub async fn create_token( + State(state): State, + body: Result, +) -> ApiResult> { + let user = log_in(body)?; + let token = state.credentials.issue(user.id, Kind::Bearer); + Ok(Json(json!({ + "accessToken": token, + "tokenType": "Bearer", + "expiresIn": TTL.as_secs(), + }))) +} + +pub async fn me( + State(state): State, + headers: HeaderMap, +) -> ApiResult> { + let Some(authorization) = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()) + else { + return Err(ApiError::unauthorized("Missing Authorization header", Some("Bearer"))); + }; + let token = match authorization.split_once(' ') { + Some((scheme, token)) if scheme.eq_ignore_ascii_case("bearer") => token.trim(), + _ => { + return Err(ApiError::unauthorized( + "Authorization header must be \"Bearer \"", + Some("Bearer"), + )); + } + }; + state.credentials.lookup(token, Kind::Bearer).map(Json).ok_or_else(|| { + ApiError::unauthorized("Invalid or expired token", Some("Bearer error=\"invalid_token\"")) + }) +} + +pub async fn create_session( + State(state): State, + headers: HeaderMap, + body: Result, +) -> ApiResult { + let user = log_in(body)?; + let id = state.credentials.issue(user.id, Kind::Session); + let cookie = format!( + "{SESSION_COOKIE}={id}; Path=/; Max-Age={}; HttpOnly; SameSite=Lax{}", + TTL.as_secs(), + secure_attribute(&headers) + ); + let mut res = Json(user).into_response(); + res.headers_mut().insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).expect("ascii")); + Ok(res) +} + +pub async fn session( + State(state): State, + headers: HeaderMap, +) -> ApiResult> { + let Some(id) = session_cookie(&headers) else { + return Err(ApiError::unauthorized("Missing session cookie", None)); + }; + state + .credentials + .lookup(id, Kind::Session) + .map(Json) + .ok_or_else(|| ApiError::unauthorized("Invalid or expired session", None)) +} + +pub async fn delete_session(State(state): State, headers: HeaderMap) -> Response { + if let Some(id) = session_cookie(&headers) { + state.credentials.revoke(id); + } + let cookie = format!( + "{SESSION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax{}", + secure_attribute(&headers) + ); + let mut res = StatusCode::NO_CONTENT.into_response(); + res.headers_mut().insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).expect("ascii")); + res +} + +fn session_cookie(headers: &HeaderMap) -> Option<&str> { + headers + .get_all(header::COOKIE) + .iter() + .filter_map(|v| v.to_str().ok()) + .flat_map(|v| v.split(';')) + .filter_map(|pair| pair.trim().split_once('=')) + .find(|(name, _)| *name == SESSION_COOKIE) + .map(|(_, value)| value) +} + +/// `Secure` only when the request reached the proxy over HTTPS, so the cookie still works +/// against a local instance on plain HTTP. +fn secure_attribute(headers: &HeaderMap) -> &'static str { + let https = headers + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.eq_ignore_ascii_case("https")); + if https { "; Secure" } else { "" } +} diff --git a/crates-server/yaak-playground/src/config.rs b/crates-server/yaak-playground/src/config.rs new file mode 100644 index 000000000..249e8307c --- /dev/null +++ b/crates-server/yaak-playground/src/config.rs @@ -0,0 +1,59 @@ +use clap::Parser; +use std::net::{SocketAddr, ToSocketAddrs}; + +/// A small, disposable API for trying Yaak against. +#[derive(Parser, Debug, Clone)] +#[command(name = "yaak-playground", version, about, long_about = None)] +pub struct Config { + /// Interface to listen on. 0.0.0.0 inside a container. + #[arg(long, env = "HOST", default_value = "127.0.0.1")] + pub host: String, + + #[arg(long, env = "PORT", default_value_t = 9228)] + pub port: u16, + + /// Requests allowed per client IP per minute. 0 disables the limit. + #[arg( + long, + env = "YAAK_PLAYGROUND_RATE_LIMIT_PER_MINUTE", + default_value_t = 300 + )] + pub rate_limit_per_minute: u32, + + /// Take the client IP from `X-Forwarded-For` (first hop) instead of the socket. Only behind + /// a load balancer that sets the header. + #[arg( + long, + env = "YAAK_PLAYGROUND_TRUST_FORWARDED_FOR", + default_value_t = false + )] + pub trust_forwarded_for: bool, + + /// Seconds after a client's first write before its posts go back to the sample data. + #[arg(long, env = "YAAK_PLAYGROUND_RESET_AFTER_SECS", default_value_t = 3600)] + pub reset_after_secs: u64, + + /// Clients whose writes are kept at once. The oldest is reset early to make room. + #[arg(long, env = "YAAK_PLAYGROUND_MAX_CLIENTS", default_value_t = 2000)] + pub max_clients: usize, + + #[arg( + long, + env = "YAAK_PLAYGROUND_MAX_POSTS_PER_CLIENT", + default_value_t = 100 + )] + pub max_posts_per_client: usize, + + #[arg(long, env = "YAAK_PLAYGROUND_MAX_REQUEST_BYTES", default_value_t = 64 * 1024)] + pub max_request_bytes: usize, +} + +impl Config { + pub fn listen_addr(&self) -> Result { + (self.host.as_str(), self.port) + .to_socket_addrs() + .map_err(|e| format!("Invalid HOST {:?}: {e}", self.host))? + .next() + .ok_or_else(|| format!("HOST {:?} resolved to no address", self.host)) + } +} diff --git a/crates-server/yaak-playground/src/data.rs b/crates-server/yaak-playground/src/data.rs new file mode 100644 index 000000000..eedd653a8 --- /dev/null +++ b/crates-server/yaak-playground/src/data.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; +use std::sync::LazyLock; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct User { + pub id: u64, + pub name: String, + pub username: String, + pub email: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Post { + pub id: u64, + pub user_id: u64, + pub title: String, + pub body: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Todo { + pub id: u64, + pub user_id: u64, + pub title: String, + pub completed: bool, +} + +#[derive(Deserialize)] +struct Seed { + users: Vec, + posts: Vec, + todos: Vec, +} + +static SEED: LazyLock = + LazyLock::new(|| serde_json::from_str(include_str!("seed.json")).expect("valid seed.json")); + +pub fn users() -> &'static [User] { + &SEED.users +} + +pub fn posts() -> &'static [Post] { + &SEED.posts +} + +pub fn todos() -> &'static [Todo] { + &SEED.todos +} + +pub fn user(id: u64) -> Option<&'static User> { + users().iter().find(|u| u.id == id) +} + +pub fn user_by_username(username: &str) -> Option<&'static User> { + users().iter().find(|u| u.username == username) +} diff --git a/crates-server/yaak-playground/src/error.rs b/crates-server/yaak-playground/src/error.rs new file mode 100644 index 000000000..d8fba04c9 --- /dev/null +++ b/crates-server/yaak-playground/src/error.rs @@ -0,0 +1,57 @@ +use axum::extract::rejection::BytesRejection; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Json, Response}; +use bytes::Bytes; +use serde::de::DeserializeOwned; +use serde_json::json; + +pub type ApiResult = Result; + +pub struct ApiError { + status: StatusCode, + message: String, + challenge: Option<&'static str>, +} + +impl ApiError { + pub fn new(status: StatusCode, message: impl Into) -> Self { + Self { status, message: message.into(), challenge: None } + } + + pub fn bad_request(message: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, message) + } + + pub fn not_found(message: impl Into) -> Self { + Self::new(StatusCode::NOT_FOUND, message) + } + + /// `challenge` becomes the `WWW-Authenticate` header. + pub fn unauthorized(message: impl Into, challenge: Option<&'static str>) -> Self { + Self { status: StatusCode::UNAUTHORIZED, message: message.into(), challenge } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let mut res = (self.status, Json(json!({ "error": self.message }))).into_response(); + if let Some(challenge) = self.challenge { + res.headers_mut().insert(header::WWW_AUTHENTICATE, HeaderValue::from_static(challenge)); + } + res + } +} + +pub fn parse_id(raw: &str, noun: &str) -> ApiResult { + raw.parse().map_err(|_| ApiError::not_found(format!("No {noun} with ID {raw}"))) +} + +/// Taken as a `Result` so an oversized body gets a JSON error like everything else. +pub fn parse_json(body: Result) -> ApiResult { + let body = body.map_err(|e| ApiError::new(e.status(), e.body_text()))?; + if body.is_empty() { + return Err(ApiError::bad_request("Request body must be JSON")); + } + serde_json::from_slice(&body) + .map_err(|e| ApiError::bad_request(format!("Request body is not valid: {e}"))) +} diff --git a/crates-server/yaak-playground/src/lib.rs b/crates-server/yaak-playground/src/lib.rs new file mode 100644 index 000000000..e0926bba5 --- /dev/null +++ b/crates-server/yaak-playground/src/lib.rs @@ -0,0 +1,170 @@ +//! yaak-playground: the API behind yaak.run, for trying Yaak without bringing your own. +//! +//! Reads come from fixed sample data. Writes are kept per client IP and reset after a while, +//! so nothing one caller sends is ever served to another. Nothing touches disk. + +mod auth; +mod config; +mod data; +mod error; +mod limits; +mod resources; +mod store; + +use axum::Router; +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}; +pub use config::Config; +use error::ApiError; +use limits::RateLimiter; +use serde_json::{Value, json}; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; +use store::PostStore; +use tower_http::cors::{AllowOrigin, CorsLayer}; + +const OPENAPI: &str = include_str!("openapi.json"); + +#[derive(Clone)] +struct AppState { + config: Arc, + rate_limiter: Arc, + posts: Arc, + credentials: Arc, +} + +#[derive(Clone, Copy)] +struct ClientIp(IpAddr); + +/// Serve with `into_make_service_with_connect_info::()`; the client's address +/// keys both the rate limit and its private copy of the posts. +pub fn router(config: Config) -> Router { + let state = AppState { + rate_limiter: Arc::new(RateLimiter::new(config.rate_limit_per_minute)), + posts: Arc::new(PostStore::new( + Duration::from_secs(config.reset_after_secs), + config.max_clients, + config.max_posts_per_client, + )), + credentials: Arc::new(auth::Credentials::new()), + config: Arc::new(config), + }; + + let cors = CorsLayer::new() + .allow_origin(AllowOrigin::any()) + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::OPTIONS, + ]) + .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]) + .expose_headers([ + header::LOCATION, + header::RETRY_AFTER, + header::WWW_AUTHENTICATE, + ]); + + Router::new() + .route("/", get(index)) + .route("/health", get(health)) + .route("/openapi.json", get(openapi)) + .route("/users", get(resources::list_users)) + .route("/users/:id", get(resources::get_user)) + .route("/users/:id/todos", get(resources::list_user_todos)) + .route("/todos", get(resources::list_todos)) + .route("/posts", get(resources::list_posts).post(resources::create_post)) + .route( + "/posts/:id", + get(resources::get_post) + .put(resources::replace_post) + .patch(resources::update_post) + .delete(resources::delete_post), + ) + .route("/auth/token", post(auth::create_token)) + .route("/auth/me", get(auth::me)) + .route( + "/auth/session", + post(auth::create_session).get(auth::session).delete(auth::delete_session), + ) + .fallback(not_found) + .layer(middleware::from_fn_with_state(state.clone(), identify_client)) + .layer(DefaultBodyLimit::max(state.config.max_request_bytes)) + .layer(cors) + .with_state(state) +} + +async fn identify_client( + State(state): State, + ConnectInfo(peer): ConnectInfo, + mut req: Request, + next: Next, +) -> Response { + let ip = client_ip(&state.config, req.headers(), peer); + if let Err(wait) = state.rate_limiter.check(ip) { + let secs = wait.as_secs().max(1); + let mut res = ApiError::new( + StatusCode::TOO_MANY_REQUESTS, + format!("Rate limit reached. Try again in {secs}s"), + ) + .into_response(); + res.headers_mut().insert(header::RETRY_AFTER, HeaderValue::from(secs)); + return res; + } + req.extensions_mut().insert(ClientIp(ip)); + next.run(req).await +} + +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::() + { + return ip; + } + peer.ip() +} + +async fn index(State(state): State) -> Json { + let spec: Value = serde_json::from_str(OPENAPI).expect("valid openapi.json"); + Json(json!({ + "name": "Yaak Playground", + "description": "A small API for trying out Yaak. Posts you create, change, or delete are only visible to you, and go back to the sample data after a while.", + "resetAfterSeconds": state.config.reset_after_secs, + "login": { "username": "ada", "password": auth::PASSWORD }, + "openapi": "/openapi.json", + "endpoints": operations(&spec), + })) +} + +/// Every `METHOD /path` the spec documents. +fn operations(spec: &Value) -> Vec { + let mut ops = Vec::new(); + for (path, item) in spec["paths"].as_object().into_iter().flatten() { + for method in ["get", "post", "put", "patch", "delete"] { + if item.get(method).is_some() { + ops.push(format!("{} {path}", method.to_uppercase())); + } + } + } + ops +} + +async fn health() -> Json { + Json(json!({ "ok": true, "version": env!("CARGO_PKG_VERSION") })) +} + +async fn openapi() -> Response { + ([(header::CONTENT_TYPE, "application/json")], OPENAPI).into_response() +} + +async fn not_found() -> ApiError { + ApiError::not_found("Not found. GET / lists every endpoint") +} diff --git a/crates-server/yaak-playground/src/limits.rs b/crates-server/yaak-playground/src/limits.rs new file mode 100644 index 000000000..7481c3831 --- /dev/null +++ b/crates-server/yaak-playground/src/limits.rs @@ -0,0 +1,50 @@ +use std::collections::HashMap; +use std::net::IpAddr; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +/// One token bucket per client IP, refilled continuously. +pub struct RateLimiter { + per_minute: u32, + buckets: Mutex>, +} + +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()); + 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))) + } + } +} diff --git a/crates-server/yaak-playground/src/main.rs b/crates-server/yaak-playground/src/main.rs new file mode 100644 index 000000000..56482c186 --- /dev/null +++ b/crates-server/yaak-playground/src/main.rs @@ -0,0 +1,29 @@ +use clap::Parser; +use log::info; +use std::net::SocketAddr; +use yaak_playground::{Config, router}; + +#[tokio::main] +async fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + let config = Config::parse(); + let bind = config.listen_addr().unwrap_or_else(|e| { + eprintln!("{e}"); + std::process::exit(1); + }); + let app = router(config); + + let listener = tokio::net::TcpListener::bind(bind).await.unwrap_or_else(|e| { + eprintln!("Failed to bind {bind}: {e}"); + std::process::exit(1); + }); + info!("yaak-playground listening on http://{bind}"); + + axum::serve(listener, app.into_make_service_with_connect_info::()) + .with_graceful_shutdown(async { + let _ = tokio::signal::ctrl_c().await; + info!("Shutting down"); + }) + .await + .expect("server error"); +} diff --git a/crates-server/yaak-playground/src/openapi.json b/crates-server/yaak-playground/src/openapi.json new file mode 100644 index 000000000..1aaebd98c --- /dev/null +++ b/crates-server/yaak-playground/src/openapi.json @@ -0,0 +1,396 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Yaak Playground", + "version": "1.0.0", + "description": "A small API for trying out Yaak. Users and todos are fixed sample data. Posts you create, change, or delete are only visible to you, and go back to the sample data after a while.\n\nLog in with any username (like `ada`) and the password `yaak`." + }, + "servers": [{ "url": "https://yaak.run" }], + "tags": [ + { "name": "Users" }, + { "name": "Posts" }, + { "name": "Todos" }, + { "name": "Auth", "description": "Two ways to stay logged in: a bearer token you send yourself, or a session cookie the cookie jar sends for you." } + ], + "paths": { + "/users": { + "get": { + "tags": ["Users"], + "operationId": "listUsers", + "summary": "List users", + "responses": { + "200": { + "description": "Every user", + "content": { + "application/json": { + "schema": { "type": "array", "items": { "$ref": "#/components/schemas/User" } } + } + } + } + } + } + }, + "/users/{id}": { + "get": { + "tags": ["Users"], + "operationId": "getUser", + "summary": "Get a user", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "responses": { + "200": { + "description": "The user", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } + }, + "404": { "$ref": "#/components/responses/NotFound" } + } + } + }, + "/users/{id}/todos": { + "get": { + "tags": ["Users", "Todos"], + "operationId": "listUserTodos", + "summary": "List a user's todos", + "parameters": [ + { "$ref": "#/components/parameters/Id" }, + { "$ref": "#/components/parameters/Completed" }, + { "$ref": "#/components/parameters/Limit" } + ], + "responses": { + "200": { + "description": "The user's todos", + "content": { + "application/json": { + "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Todo" } } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + } + }, + "/todos": { + "get": { + "tags": ["Todos"], + "operationId": "listTodos", + "summary": "List todos", + "parameters": [ + { "$ref": "#/components/parameters/UserId" }, + { "$ref": "#/components/parameters/Completed" }, + { "$ref": "#/components/parameters/Limit" } + ], + "responses": { + "200": { + "description": "Matching todos", + "content": { + "application/json": { + "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Todo" } } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" } + } + } + }, + "/posts": { + "get": { + "tags": ["Posts"], + "operationId": "listPosts", + "summary": "List posts", + "parameters": [ + { "$ref": "#/components/parameters/UserId" }, + { "$ref": "#/components/parameters/Limit" } + ], + "responses": { + "200": { + "description": "Matching posts", + "content": { + "application/json": { + "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Post" } } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" } + } + }, + "post": { + "tags": ["Posts"], + "operationId": "createPost", + "summary": "Create a post", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostInput" } } } + }, + "responses": { + "201": { + "description": "The new post", + "headers": { + "Location": { "description": "Where to fetch the new post", "schema": { "type": "string" } } + }, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "409": { + "description": "You already have the most posts allowed", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + } + } + } + }, + "/posts/{id}": { + "get": { + "tags": ["Posts"], + "operationId": "getPost", + "summary": "Get a post", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "responses": { + "200": { + "description": "The post", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } } + }, + "404": { "$ref": "#/components/responses/NotFound" } + } + }, + "put": { + "tags": ["Posts"], + "operationId": "replacePost", + "summary": "Replace a post", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostInput" } } } + }, + "responses": { + "200": { + "description": "The updated post", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + }, + "patch": { + "tags": ["Posts"], + "operationId": "updatePost", + "summary": "Update part of a post", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostPatch" } } } + }, + "responses": { + "200": { + "description": "The updated post", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + }, + "delete": { + "tags": ["Posts"], + "operationId": "deletePost", + "summary": "Delete a post", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "responses": { + "204": { "description": "Deleted" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + } + }, + "/auth/token": { + "post": { + "tags": ["Auth"], + "operationId": "createToken", + "summary": "Log in for a bearer token", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Login" } } } + }, + "responses": { + "200": { + "description": "A token to send as `Authorization: Bearer `", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Token" } } } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/auth/me": { + "get": { + "tags": ["Auth"], + "operationId": "getMe", + "summary": "Get the user a bearer token belongs to", + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "The logged-in user", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/auth/session": { + "post": { + "tags": ["Auth"], + "operationId": "createSession", + "summary": "Log in with a session cookie", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Login" } } } + }, + "responses": { + "200": { + "description": "The logged-in user. The session cookie comes back in `Set-Cookie`.", + "headers": { + "Set-Cookie": { "description": "The `session` cookie", "schema": { "type": "string" } } + }, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + }, + "get": { + "tags": ["Auth"], + "operationId": "getSession", + "summary": "Get the user a session cookie belongs to", + "security": [{ "cookieAuth": [] }], + "responses": { + "200": { + "description": "The logged-in user", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + }, + "delete": { + "tags": ["Auth"], + "operationId": "deleteSession", + "summary": "Log out", + "responses": { + "204": { "description": "Logged out, and the cookie is cleared" } + } + } + } + }, + "components": { + "parameters": { + "Id": { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "integer", "minimum": 1 }, + "example": 1 + }, + "UserId": { + "name": "userId", + "in": "query", + "description": "Only return items belonging to this user", + "schema": { "type": "integer", "minimum": 1 } + }, + "Completed": { + "name": "completed", + "in": "query", + "schema": { "type": "boolean" } + }, + "Limit": { + "name": "limit", + "in": "query", + "description": "Return at most this many items", + "schema": { "type": "integer", "minimum": 0 } + } + }, + "responses": { + "BadRequest": { + "description": "Something in the request is invalid", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + }, + "NotFound": { + "description": "Nothing has that ID", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + }, + "Unauthorized": { + "description": "Missing or invalid credentials", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + } + }, + "schemas": { + "User": { + "type": "object", + "required": ["id", "name", "username", "email"], + "properties": { + "id": { "type": "integer", "example": 1 }, + "name": { "type": "string", "example": "Ada Lovelace" }, + "username": { "type": "string", "example": "ada" }, + "email": { "type": "string", "format": "email", "example": "ada@example.com" } + } + }, + "Post": { + "type": "object", + "required": ["id", "userId", "title", "body"], + "properties": { + "id": { "type": "integer", "example": 1 }, + "userId": { "type": "integer", "example": 1 }, + "title": { "type": "string" }, + "body": { "type": "string" } + } + }, + "PostInput": { + "type": "object", + "required": ["userId", "title", "body"], + "properties": { + "userId": { "type": "integer", "example": 1 }, + "title": { "type": "string", "maxLength": 200, "example": "Hello from Yaak" }, + "body": { "type": "string", "maxLength": 2000, "example": "My first post" } + } + }, + "PostPatch": { + "type": "object", + "properties": { + "userId": { "type": "integer" }, + "title": { "type": "string", "maxLength": 200 }, + "body": { "type": "string", "maxLength": 2000 } + } + }, + "Todo": { + "type": "object", + "required": ["id", "userId", "title", "completed"], + "properties": { + "id": { "type": "integer", "example": 1 }, + "userId": { "type": "integer", "example": 1 }, + "title": { "type": "string" }, + "completed": { "type": "boolean" } + } + }, + "Login": { + "type": "object", + "required": ["username", "password"], + "properties": { + "username": { "type": "string", "example": "ada" }, + "password": { "type": "string", "example": "yaak" } + } + }, + "Token": { + "type": "object", + "required": ["accessToken", "tokenType", "expiresIn"], + "properties": { + "accessToken": { "type": "string" }, + "tokenType": { "type": "string", "const": "Bearer" }, + "expiresIn": { "type": "integer", "description": "Seconds until the token expires", "example": 3600 } + } + }, + "Error": { + "type": "object", + "required": ["error"], + "properties": { "error": { "type": "string" } } + } + }, + "securitySchemes": { + "bearerAuth": { "type": "http", "scheme": "bearer" }, + "cookieAuth": { "type": "apiKey", "in": "cookie", "name": "session" } + } + } +} diff --git a/crates-server/yaak-playground/src/resources.rs b/crates-server/yaak-playground/src/resources.rs new file mode 100644 index 000000000..aad0e1f1c --- /dev/null +++ b/crates-server/yaak-playground/src/resources.rs @@ -0,0 +1,229 @@ +use crate::data::{self, Post, Todo, User}; +use crate::error::{ApiError, ApiResult, parse_id, parse_json}; +use crate::{AppState, ClientIp}; +use axum::extract::rejection::BytesRejection; +use axum::extract::{Extension, Path, Query, State}; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Json, Response}; +use bytes::Bytes; +use serde::Deserialize; +use std::collections::HashMap; + +const MAX_TITLE_CHARS: usize = 200; +const MAX_BODY_CHARS: usize = 2000; + +type Params = Query>; + +struct Filters { + user_id: Option, + completed: Option, + limit: Option, +} + +fn filters(params: &HashMap) -> ApiResult { + fn parse( + params: &HashMap, + name: &str, + expected: &str, + ) -> ApiResult> { + params + .get(name) + .map(|v| { + v.parse().map_err(|_| { + ApiError::bad_request(format!("Query parameter `{name}` must be {expected}")) + }) + }) + .transpose() + } + Ok(Filters { + user_id: parse(params, "userId", "a user ID")?, + completed: parse(params, "completed", "true or false")?, + limit: parse(params, "limit", "a number")?, + }) +} + +pub async fn list_users() -> Json<&'static [User]> { + Json(data::users()) +} + +pub async fn get_user(Path(id): Path) -> ApiResult> { + let user_id = parse_id(&id, "user")?; + data::user(user_id) + .map(Json) + .ok_or_else(|| ApiError::not_found(format!("No user with ID {id}"))) +} + +pub async fn list_todos(Query(params): Params) -> ApiResult>> { + let f = filters(¶ms)?; + Ok(Json(select_todos(f.user_id, f.completed, f.limit))) +} + +pub async fn list_user_todos( + Path(id): Path, + Query(params): Params, +) -> ApiResult>> { + let user = get_user(Path(id)).await?; + let f = filters(¶ms)?; + Ok(Json(select_todos(Some(user.id), f.completed, f.limit))) +} + +fn select_todos( + user_id: Option, + completed: Option, + limit: Option, +) -> Vec<&'static Todo> { + data::todos() + .iter() + .filter(|t| user_id.is_none_or(|id| t.user_id == id)) + .filter(|t| completed.is_none_or(|c| t.completed == c)) + .take(limit.unwrap_or(usize::MAX)) + .collect() +} + +pub async fn list_posts( + State(state): State, + Extension(ClientIp(ip)): Extension, + Query(params): Params, +) -> ApiResult>> { + let f = filters(¶ms)?; + let posts = state.posts.read(ip, |posts| { + posts + .iter() + .filter(|p| f.user_id.is_none_or(|id| p.user_id == id)) + .take(f.limit.unwrap_or(usize::MAX)) + .cloned() + .collect() + }); + Ok(Json(posts)) +} + +pub async fn get_post( + State(state): State, + Extension(ClientIp(ip)): Extension, + Path(id): Path, +) -> ApiResult> { + let post_id = parse_id(&id, "post")?; + state + .posts + .read(ip, |posts| posts.iter().find(|p| p.id == post_id).cloned()) + .map(Json) + .ok_or_else(|| post_not_found(&id)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct NewPost { + user_id: u64, + title: String, + body: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PostPatch { + user_id: Option, + title: Option, + body: Option, +} + +pub async fn create_post( + State(state): State, + Extension(ClientIp(ip)): Extension, + body: Result, +) -> ApiResult { + let input: NewPost = parse_json(body)?; + validate(input.user_id, &input.title, &input.body)?; + let max = state.posts.max_posts; + let post = state.posts.write(ip, |client| { + if client.posts.len() >= max { + return Err(ApiError::new( + StatusCode::CONFLICT, + format!( + "You can have at most {max} posts. Delete one first, or wait for the reset" + ), + )); + } + Ok(client.insert(input.user_id, input.title, input.body)) + })?; + let location = HeaderValue::from_str(&format!("/posts/{}", post.id)).expect("ascii"); + Ok((StatusCode::CREATED, [(header::LOCATION, location)], Json(post)).into_response()) +} + +pub async fn replace_post( + State(state): State, + Extension(ClientIp(ip)): Extension, + Path(id): Path, + body: Result, +) -> ApiResult> { + let post_id = parse_id(&id, "post")?; + let input: NewPost = parse_json(body)?; + validate(input.user_id, &input.title, &input.body)?; + state.posts.write(ip, |client| { + let post = + client.posts.iter_mut().find(|p| p.id == post_id).ok_or_else(|| post_not_found(&id))?; + post.user_id = input.user_id; + post.title = input.title; + post.body = input.body; + Ok(Json(post.clone())) + }) +} + +pub async fn update_post( + State(state): State, + Extension(ClientIp(ip)): Extension, + Path(id): Path, + body: Result, +) -> ApiResult> { + let post_id = parse_id(&id, "post")?; + let patch: PostPatch = parse_json(body)?; + state.posts.write(ip, |client| { + let post = + client.posts.iter_mut().find(|p| p.id == post_id).ok_or_else(|| post_not_found(&id))?; + let user_id = patch.user_id.unwrap_or(post.user_id); + let title = patch.title.unwrap_or_else(|| post.title.clone()); + let body = patch.body.unwrap_or_else(|| post.body.clone()); + validate(user_id, &title, &body)?; + post.user_id = user_id; + post.title = title; + post.body = body; + Ok(Json(post.clone())) + }) +} + +pub async fn delete_post( + State(state): State, + Extension(ClientIp(ip)): Extension, + Path(id): Path, +) -> ApiResult { + let post_id = parse_id(&id, "post")?; + state.posts.write(ip, |client| { + let index = + client.posts.iter().position(|p| p.id == post_id).ok_or_else(|| post_not_found(&id))?; + client.posts.remove(index); + Ok(StatusCode::NO_CONTENT) + }) +} + +fn post_not_found(id: &str) -> ApiError { + ApiError::not_found(format!("No post with ID {id}")) +} + +fn validate(user_id: u64, title: &str, body: &str) -> ApiResult<()> { + if data::user(user_id).is_none() { + return Err(ApiError::bad_request(format!("No user with ID {user_id}"))); + } + if title.trim().is_empty() { + return Err(ApiError::bad_request("`title` can't be empty")); + } + if title.chars().count() > MAX_TITLE_CHARS { + return Err(ApiError::bad_request(format!( + "`title` must be {MAX_TITLE_CHARS} characters or fewer" + ))); + } + if body.chars().count() > MAX_BODY_CHARS { + return Err(ApiError::bad_request(format!( + "`body` must be {MAX_BODY_CHARS} characters or fewer" + ))); + } + Ok(()) +} diff --git a/crates-server/yaak-playground/src/seed.json b/crates-server/yaak-playground/src/seed.json new file mode 100644 index 000000000..fa724a9e3 --- /dev/null +++ b/crates-server/yaak-playground/src/seed.json @@ -0,0 +1,88 @@ +{ + "users": [ + { "id": 1, "name": "Ada Lovelace", "username": "ada", "email": "ada@example.com" }, + { "id": 2, "name": "Grace Hopper", "username": "grace", "email": "grace@example.com" }, + { "id": 3, "name": "Alan Turing", "username": "alan", "email": "alan@example.com" }, + { "id": 4, "name": "Katherine Johnson", "username": "katherine", "email": "katherine@example.com" }, + { "id": 5, "name": "Margaret Hamilton", "username": "margaret", "email": "margaret@example.com" } + ], + "posts": [ + { + "id": 1, + "userId": 1, + "title": "Notes on the Analytical Engine", + "body": "Requests are just messages. The trick is knowing exactly what you sent." + }, + { + "id": 2, + "userId": 1, + "title": "Why I keep my variables in environments", + "body": "Switching from staging to production should be one click, not a find and replace." + }, + { + "id": 3, + "userId": 2, + "title": "Easier to ask forgiveness than permission", + "body": "Unless the API returns a 403. Then it's time to check your token." + }, + { + "id": 4, + "userId": 2, + "title": "Debugging the first bug", + "body": "The Timeline tab shows every redirect, header, and byte. No moths required." + }, + { + "id": 5, + "userId": 3, + "title": "Can machines send requests?", + "body": "This one just did. Look through the response headers to see what came back." + }, + { + "id": 6, + "userId": 3, + "title": "On computable status codes", + "body": "2xx is good news, 4xx usually means the request needs fixing, and 5xx usually means the server does." + }, + { + "id": 7, + "userId": 4, + "title": "Checking the math on pagination", + "body": "Add a limit query parameter to get fewer results back." + }, + { + "id": 8, + "userId": 4, + "title": "Plotting a course between requests", + "body": "Pull a value out of one response and send it in the next. That's all chaining is." + }, + { + "id": 9, + "userId": 5, + "title": "Error handling saved the landing", + "body": "An API that returns clear errors is an API you can build on." + }, + { + "id": 10, + "userId": 5, + "title": "Logging in, then everything else", + "body": "Get a token from one request and use it for the rest. Set it once on a folder and every request inside inherits it." + } + ], + "todos": [ + { "id": 1, "userId": 1, "title": "Send your first request", "completed": true }, + { "id": 2, "userId": 1, "title": "Create an environment", "completed": false }, + { "id": 3, "userId": 1, "title": "Switch to a sub-environment", "completed": false }, + { "id": 4, "userId": 2, "title": "Import an OpenAPI spec", "completed": true }, + { "id": 5, "userId": 2, "title": "Add a header to a folder", "completed": false }, + { "id": 6, "userId": 2, "title": "Read the response timeline", "completed": true }, + { "id": 7, "userId": 3, "title": "Chain a value from another response", "completed": false }, + { "id": 8, "userId": 3, "title": "Log in and use the token", "completed": false }, + { "id": 9, "userId": 3, "title": "Copy a request as curl", "completed": true }, + { "id": 10, "userId": 4, "title": "Sync a workspace to a folder", "completed": false }, + { "id": 11, "userId": 4, "title": "Commit the workspace to Git", "completed": false }, + { "id": 12, "userId": 4, "title": "Encrypt a secret", "completed": false }, + { "id": 13, "userId": 5, "title": "Create a post", "completed": false }, + { "id": 14, "userId": 5, "title": "Delete the post you created", "completed": false }, + { "id": 15, "userId": 5, "title": "Delete the example workspace", "completed": false } + ] +} diff --git a/crates-server/yaak-playground/src/store.rs b/crates-server/yaak-playground/src/store.rs new file mode 100644 index 000000000..71ccb9ea6 --- /dev/null +++ b/crates-server/yaak-playground/src/store.rs @@ -0,0 +1,83 @@ +use crate::data::{self, Post}; +use std::collections::HashMap; +use std::net::IpAddr; +use std::sync::{Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +/// Posts as each client sees them. A client that has never written reads the sample data; its +/// first write takes a private copy, which is thrown away `reset_after` later. Nothing one +/// client writes is ever served to another. +pub struct PostStore { + clients: Mutex>, + reset_after: Duration, + max_clients: usize, + pub max_posts: usize, +} + +pub struct ClientPosts { + pub posts: Vec, + next_id: u64, + created: Instant, +} + +impl ClientPosts { + pub fn insert(&mut self, user_id: u64, title: String, body: String) -> Post { + let post = Post { id: self.next_id, user_id, title, body }; + self.next_id += 1; + self.posts.push(post.clone()); + post + } +} + +impl PostStore { + pub fn new(reset_after: Duration, max_clients: usize, max_posts: usize) -> Self { + Self { clients: Mutex::new(HashMap::new()), reset_after, max_clients, max_posts } + } + + pub fn read(&self, client: IpAddr, f: impl FnOnce(&[Post]) -> R) -> R { + let mut clients = self.lock(); + self.drop_if_expired(&mut clients, client, Instant::now()); + match clients.get(&client) { + Some(c) => f(&c.posts), + None => f(data::posts()), + } + } + + pub fn write(&self, client: IpAddr, f: impl FnOnce(&mut ClientPosts) -> R) -> R { + let mut clients = self.lock(); + let now = Instant::now(); + self.drop_if_expired(&mut clients, client, now); + + if !clients.contains_key(&client) { + if clients.len() >= self.max_clients { + clients.retain(|_, c| now.duration_since(c.created) < self.reset_after); + } + if clients.len() >= self.max_clients + && let Some(oldest) = + clients.iter().min_by_key(|(_, c)| c.created).map(|(ip, _)| *ip) + { + clients.remove(&oldest); + } + let posts = data::posts().to_vec(); + let next_id = posts.iter().map(|p| p.id).max().unwrap_or(0) + 1; + clients.insert(client, ClientPosts { posts, next_id, created: now }); + } + + f(clients.get_mut(&client).expect("inserted above")) + } + + fn lock(&self) -> MutexGuard<'_, HashMap> { + self.clients.lock().unwrap_or_else(|e| e.into_inner()) + } + + fn drop_if_expired( + &self, + clients: &mut HashMap, + client: IpAddr, + now: Instant, + ) { + if clients.get(&client).is_some_and(|c| now.duration_since(c.created) >= self.reset_after) { + clients.remove(&client); + } + } +} diff --git a/crates-server/yaak-playground/tests/api.rs b/crates-server/yaak-playground/tests/api.rs new file mode 100644 index 000000000..b08989f4b --- /dev/null +++ b/crates-server/yaak-playground/tests/api.rs @@ -0,0 +1,262 @@ +use axum::Router; +use axum::body::{Body, to_bytes}; +use axum::extract::ConnectInfo; +use axum::http::{HeaderMap, Request, StatusCode, header}; +use serde_json::{Value, json}; +use std::collections::BTreeSet; +use std::net::SocketAddr; +use tower::ServiceExt; +use yaak_playground::{Config, router}; + +const ALICE: [u8; 4] = [203, 0, 113, 1]; +const BOB: [u8; 4] = [203, 0, 113, 2]; + +fn config() -> Config { + Config { + host: "127.0.0.1".to_string(), + port: 0, + rate_limit_per_minute: 1000, + trust_forwarded_for: false, + reset_after_secs: 3600, + max_clients: 10, + max_posts_per_client: 20, + max_request_bytes: 4096, + } +} + +struct Res { + status: StatusCode, + headers: HeaderMap, + json: Value, +} + +async fn call( + app: &Router, + client: [u8; 4], + method: &str, + uri: &str, + headers: &[(&str, &str)], + body: Option, +) -> Res { + let mut req = Request::builder().method(method).uri(uri); + for (name, value) in headers { + req = req.header(*name, *value); + } + let body = match body { + Some(v) => Body::from(v.to_string()), + None => Body::empty(), + }; + let mut req = req.body(body).unwrap(); + req.extensions_mut().insert(ConnectInfo(SocketAddr::from((client, 40000)))); + let res = app.clone().oneshot(req).await.unwrap(); + let status = res.status(); + let headers = res.headers().clone(); + let bytes = to_bytes(res.into_body(), 1024 * 1024).await.unwrap(); + let json = if bytes.is_empty() { Value::Null } else { serde_json::from_slice(&bytes).unwrap() }; + Res { status, headers, json } +} + +#[tokio::test] +async fn users_and_todos() { + let app = router(config()); + let res = call(&app, ALICE, "GET", "/users", &[], None).await; + assert_eq!(res.status, StatusCode::OK); + assert_eq!(res.json[0]["username"], "ada"); + + let res = call(&app, ALICE, "GET", "/users/2", &[], None).await; + assert_eq!(res.json["name"], "Grace Hopper"); + + for uri in ["/users/99", "/users/abc", "/users/99/todos"] { + let res = call(&app, ALICE, "GET", uri, &[], None).await; + assert_eq!(res.status, StatusCode::NOT_FOUND, "{uri}"); + assert!(res.json["error"].is_string()); + } + + let res = call(&app, ALICE, "GET", "/users/1/todos?completed=false&limit=1", &[], None).await; + assert_eq!(res.json.as_array().unwrap().len(), 1); + assert_eq!(res.json[0]["userId"], 1); + assert_eq!(res.json[0]["completed"], false); + + let res = call(&app, ALICE, "GET", "/todos?userId=3", &[], None).await; + assert!(res.json.as_array().unwrap().iter().all(|t| t["userId"] == 3)); + + let res = call(&app, ALICE, "GET", "/todos?completed=maybe", &[], None).await; + assert_eq!(res.status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn writes_are_private_to_the_client() { + let app = router(config()); + let new_post = json!({ "userId": 1, "title": "Hello", "body": "From Yaak" }); + let res = call(&app, ALICE, "POST", "/posts", &[], Some(new_post)).await; + assert_eq!(res.status, StatusCode::CREATED); + let id = res.json["id"].as_u64().unwrap(); + assert_eq!(res.headers[header::LOCATION], format!("/posts/{id}")); + + let uri = format!("/posts/{id}"); + assert_eq!(call(&app, ALICE, "GET", &uri, &[], None).await.status, StatusCode::OK); + assert_eq!(call(&app, BOB, "GET", &uri, &[], None).await.status, StatusCode::NOT_FOUND); + + let res = call(&app, ALICE, "PATCH", &uri, &[], Some(json!({ "title": "Edited" }))).await; + assert_eq!(res.json["title"], "Edited"); + assert_eq!(res.json["body"], "From Yaak"); + + let replacement = json!({ "userId": 2, "title": "Replaced", "body": "" }); + let res = call(&app, ALICE, "PUT", &uri, &[], Some(replacement)).await; + assert_eq!(res.json["userId"], 2); + + let res = call(&app, ALICE, "DELETE", "/posts/1", &[], None).await; + assert_eq!(res.status, StatusCode::NO_CONTENT); + assert_eq!(call(&app, ALICE, "GET", "/posts/1", &[], None).await.status, StatusCode::NOT_FOUND); + assert_eq!(call(&app, BOB, "GET", "/posts/1", &[], None).await.status, StatusCode::OK); + + let res = call(&app, ALICE, "GET", "/posts?userId=2", &[], None).await; + assert!(res.json.as_array().unwrap().iter().any(|p| p["title"] == "Replaced")); +} + +#[tokio::test] +async fn invalid_posts_are_rejected() { + let app = router(config()); + let cases = [ + json!({ "title": "No user", "body": "" }), + json!({ "userId": 99, "title": "Unknown user", "body": "" }), + json!({ "userId": 1, "title": " ", "body": "" }), + json!({ "userId": 1, "title": "x".repeat(201), "body": "" }), + ]; + for body in cases { + let res = call(&app, ALICE, "POST", "/posts", &[], Some(body.clone())).await; + assert_eq!(res.status, StatusCode::BAD_REQUEST, "{body}"); + assert!(res.json["error"].is_string()); + } + + let res = call(&app, ALICE, "POST", "/posts", &[], None).await; + assert_eq!(res.status, StatusCode::BAD_REQUEST); + + let huge = json!({ "userId": 1, "title": "Big", "body": "x".repeat(5000) }); + let res = call(&app, ALICE, "POST", "/posts", &[], Some(huge)).await; + assert_eq!(res.status, StatusCode::PAYLOAD_TOO_LARGE); + assert!(res.json["error"].is_string()); +} + +#[tokio::test] +async fn post_cap_and_reset() { + let app = router(Config { max_posts_per_client: 11, ..config() }); + let post = json!({ "userId": 1, "title": "One more", "body": "" }); + assert_eq!( + call(&app, ALICE, "POST", "/posts", &[], Some(post.clone())).await.status, + StatusCode::CREATED + ); + assert_eq!( + call(&app, ALICE, "POST", "/posts", &[], Some(post)).await.status, + StatusCode::CONFLICT + ); + + let app = router(Config { reset_after_secs: 0, ..config() }); + call(&app, ALICE, "DELETE", "/posts/1", &[], None).await; + assert_eq!(call(&app, ALICE, "GET", "/posts/1", &[], None).await.status, StatusCode::OK); +} + +#[tokio::test] +async fn bearer_token_flow() { + let app = router(config()); + let res = call(&app, ALICE, "GET", "/auth/me", &[], None).await; + assert_eq!(res.status, StatusCode::UNAUTHORIZED); + assert_eq!(res.headers[header::WWW_AUTHENTICATE], "Bearer"); + + let bad = json!({ "username": "ada", "password": "nope" }); + assert_eq!( + call(&app, ALICE, "POST", "/auth/token", &[], Some(bad)).await.status, + StatusCode::UNAUTHORIZED + ); + + let good = json!({ "username": "grace", "password": "yaak" }); + let res = call(&app, ALICE, "POST", "/auth/token", &[], Some(good)).await; + assert_eq!(res.status, StatusCode::OK); + let token = res.json["accessToken"].as_str().unwrap().to_string(); + + let auth = format!("Bearer {token}"); + let res = call(&app, BOB, "GET", "/auth/me", &[("authorization", &auth)], None).await; + assert_eq!(res.json["username"], "grace"); + + let res = + call(&app, ALICE, "GET", "/auth/me", &[("authorization", "Bearer wrong")], None).await; + assert_eq!(res.status, StatusCode::UNAUTHORIZED); + + let cookie = format!("session={token}"); + let res = call(&app, ALICE, "GET", "/auth/session", &[("cookie", &cookie)], None).await; + assert_eq!(res.status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn session_cookie_flow() { + let app = router(config()); + let login = json!({ "username": "alan", "password": "yaak" }); + let res = + call(&app, ALICE, "POST", "/auth/session", &[("x-forwarded-proto", "https")], Some(login)) + .await; + assert_eq!(res.status, StatusCode::OK); + let set_cookie = res.headers[header::SET_COOKIE].to_str().unwrap(); + assert!(set_cookie.contains("HttpOnly") && set_cookie.contains("; Secure"), "{set_cookie}"); + let cookie = set_cookie.split(';').next().unwrap().to_string(); + + let headers = [("cookie", cookie.as_str())]; + let res = call(&app, ALICE, "GET", "/auth/session", &headers, None).await; + assert_eq!(res.json["username"], "alan"); + + let id = cookie.trim_start_matches("session="); + let auth = format!("Bearer {id}"); + let res = call(&app, ALICE, "GET", "/auth/me", &[("authorization", &auth)], None).await; + assert_eq!(res.status, StatusCode::UNAUTHORIZED); + + let res = call(&app, ALICE, "DELETE", "/auth/session", &headers, None).await; + assert_eq!(res.status, StatusCode::NO_CONTENT); + assert!(res.headers[header::SET_COOKIE].to_str().unwrap().contains("Max-Age=0")); + assert_eq!( + call(&app, ALICE, "GET", "/auth/session", &headers, None).await.status, + StatusCode::UNAUTHORIZED + ); +} + +#[tokio::test] +async fn rate_limit() { + let app = router(Config { rate_limit_per_minute: 2, ..config() }); + call(&app, ALICE, "GET", "/users", &[], None).await; + call(&app, ALICE, "GET", "/users", &[], None).await; + let res = call(&app, ALICE, "GET", "/users", &[], None).await; + assert_eq!(res.status, StatusCode::TOO_MANY_REQUESTS); + assert!(res.headers.contains_key(header::RETRY_AFTER)); + assert_eq!(call(&app, BOB, "GET", "/users", &[], None).await.status, StatusCode::OK); +} + +#[tokio::test] +async fn openapi_matches_the_routes() { + let app = router(config()); + let spec = call(&app, ALICE, "GET", "/openapi.json", &[], None).await.json; + let mut documented = BTreeSet::new(); + for (path, item) in spec["paths"].as_object().unwrap() { + for (method, _) in item.as_object().unwrap() { + let method = method.to_uppercase(); + let uri = path.replace("{id}", "1"); + // A fresh client per call, so a DELETE can't hide the GET that follows it + let client = [198, 51, 100, documented.len() as u8]; + let res = call(&app, client, &method, &uri, &[], None).await; + assert!( + res.status != StatusCode::NOT_FOUND && res.status != StatusCode::METHOD_NOT_ALLOWED, + "{method} {path} is documented but not routed ({})", + res.status + ); + documented.insert(format!("{method} {path}")); + } + } + + let index = call(&app, ALICE, "GET", "/", &[], None).await.json; + let listed: BTreeSet = index["endpoints"] + .as_array() + .unwrap() + .iter() + .map(|e| e.as_str().unwrap().to_string()) + .collect(); + assert_eq!(listed, documented); + + assert_eq!(call(&app, ALICE, "GET", "/nope", &[], None).await.status, StatusCode::NOT_FOUND); +} diff --git a/crates-server/yaak-web/Cargo.toml b/crates-server/yaak-web/Cargo.toml index 024b1674b..f8467ad63 100644 --- a/crates-server/yaak-web/Cargo.toml +++ b/crates-server/yaak-web/Cargo.toml @@ -7,7 +7,7 @@ description = "The server behind Yaak in the browser: executes sends, and can se # 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 +# render + storage orchestration), yaak-plugins, or the RPC router: this crate # 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. @@ -19,7 +19,7 @@ path = "src/main.rs" [dependencies] async-trait = "0.1" axum = "0.7" -base64 = "0.22.1" +base64 = "0.23.1" bytes = "1.11.1" clap = { version = "4.5", features = ["derive", "env"] } env_logger = "0.11" @@ -28,9 +28,13 @@ 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"] } +tower-http = { version = "0.7", 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 } + +[dev-dependencies] +tempfile = "3" +tower = { version = "0.5", features = ["util"] } diff --git a/crates-server/yaak-web/README.md b/crates-server/yaak-web/README.md index c30e9beb7..74558f1cd 100644 --- a/crates-server/yaak-web/README.md +++ b/crates-server/yaak-web/README.md @@ -46,7 +46,7 @@ docker run -p 8080:8080 \ 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 + _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. @@ -72,27 +72,44 @@ 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. +The dev server passes `/v1` through to this binary, so a dev build sends to its +own origin exactly like a production build does — one address to open, and no +CORS in the loop. `PORT` moves this server and the dev server follows it. A +production build also sends to its own origin, unless `VITE_YAAK_WEB_URL` was +set when it was built. + +Reaching the dev server from another machine is `HOST=0.0.0.0`. Vite allows +addresses but not names, so opening it as a hostname also needs +`ALLOWED_HOSTS=that-name`. ## Configuration -Every flag has a `YAAK_WEB_*` environment variable, so a container needs no -arguments; `--help` lists them all. +Every flag has an environment variable, so a container needs no arguments; +`--help` lists them all. The listen address uses the platform conventions — +`HOST` and `PORT` — so the published image runs unchanged on a +platform-as-a-service that assigns a port. Everything else is `YAAK_WEB_*`. -| 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. | +| Flag | Default | What | +| -------------------------- | ----------- | --------------------------------------------------------------------------------------------- | +| `--serve` | off | Also serve a built web client from this directory, on the same origin. | +| `--host` | `127.0.0.1` | Interface to listen on. The image sets `0.0.0.0`. May be a name; `localhost` resolves. | +| `--port` | `9227` | Port to listen on. The image sets `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. | + +## Logging + +Default logs contain a startup message and operational warnings and errors, not request URLs, client IPs, +DNS lookups, or per-request timing. To diagnose a self-hosted instance, opt in with +`RUST_LOG=warn,yaak_http=error,yaak_web=debug`. This includes request URLs and IPs, +so only enable it while debugging. Hosting infrastructure may maintain its own +access logs independently. ## Serving the app @@ -150,8 +167,9 @@ 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 +Refusal reasons are included in debug logs when enabled. On a public instance +(`web.yaak.app`, or anything else strangers can reach), private-network protection +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 @@ -162,7 +180,7 @@ 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. +in `lib.rs`, or in middleware on the exported router. Put TLS in front of a public instance. ## The wire @@ -172,7 +190,7 @@ in `main.rs`. Put TLS in front of a public instance. { "request": { "url": "https://…", "method": "GET", "headers": […], "body": {…}, "bodyType": null, "urlParameters": […] }, "settings": { "validateCertificates": true, "followRedirects": true, "timeoutMs": 0, "sendCookies": true, "storeCookies": true }, - "cookies": [ … ] + "cookies": [ … ] } ``` @@ -184,13 +202,13 @@ 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 | +| `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 | +| `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 diff --git a/crates-server/yaak-web/src/config.rs b/crates-server/yaak-web/src/config.rs index bd86c82f1..b53fb158b 100644 --- a/crates-server/yaak-web/src/config.rs +++ b/crates-server/yaak-web/src/config.rs @@ -1,5 +1,5 @@ use clap::Parser; -use std::net::SocketAddr; +use std::net::{SocketAddr, ToSocketAddrs}; use std::path::PathBuf; /// The server behind Yaak running in a browser. @@ -10,9 +10,25 @@ use std::path::PathBuf; #[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, + /// Interface to listen on. 127.0.0.1 for a local instance; 0.0.0.0 inside a container, + /// which is what makes the port reachable from outside it. + #[arg(long, env = "HOST", default_value = "127.0.0.1")] + pub host: String, + + /// Port to listen on. Named `PORT` because that is the variable a platform-as-a-service + /// assigns and expects to be obeyed, so the same image runs there and under `docker run` + /// with nothing changed. + #[arg(long, env = "PORT", default_value_t = 9227)] + pub port: u16, + + /// Port the app is served on elsewhere. A browser that opens this server directly is + /// redirected there, keeping whatever hostname it used — so `home:9227` becomes + /// `home:1424` without this server knowing what "home" is. + /// + /// Set by `vp run web:dev`, where the app is on the dev server and this is only the + /// executor behind it. Irrelevant with `--serve`, which puts both on one port. + #[arg(long, env = "YAAK_WEB_APP_PORT")] + pub app_port: Option, /// 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. @@ -65,3 +81,18 @@ pub struct Config { #[arg(long, env = "YAAK_WEB_TRUST_FORWARDED_FOR", default_value_t = false)] pub trust_forwarded_for: bool, } + +impl Config { + /// The address to listen on. + /// + /// Resolved rather than parsed, so `HOST` may be a name — `localhost` is the one people + /// actually type, and it is not an `IpAddr`. The first address wins; a host resolving to + /// several is a machine with several interfaces, and any of them is a listen address. + pub fn listen_addr(&self) -> Result { + (self.host.as_str(), self.port) + .to_socket_addrs() + .map_err(|e| format!("Invalid HOST {:?}: {e}", self.host))? + .next() + .ok_or_else(|| format!("HOST {:?} resolved to no address", self.host)) + } +} diff --git a/crates-server/yaak-web/src/guard.rs b/crates-server/yaak-web/src/guard.rs index 284fb7a11..3055ce8d6 100644 --- a/crates-server/yaak-web/src/guard.rs +++ b/crates-server/yaak-web/src/guard.rs @@ -11,7 +11,7 @@ //! resolves every hop. use async_trait::async_trait; -use log::warn; +use log::debug; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::sync::Arc; use tokio::sync::mpsc; @@ -186,7 +186,7 @@ impl HttpSender for GuardedSender { event_tx: mpsc::Sender, ) -> yaak_http::error::Result { if let Err(reason) = self.policy.check_url(&request.url) { - warn!("Refused {} {}: {reason}", request.method, request.url); + debug!("Refused {} {}: {reason}", request.method, request.url); return Err(yaak_http::error::Error::RequestError(reason)); } self.inner.send(request, event_tx).await diff --git a/crates-server/yaak-web/src/lib.rs b/crates-server/yaak-web/src/lib.rs new file mode 100644 index 000000000..a1f50588f --- /dev/null +++ b/crates-server/yaak-web/src/lib.rs @@ -0,0 +1,295 @@ +//! 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. +//! +//! Embed [`router`] in your own Axum server, or run the `yaak-web` 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, Redirect, Response}; +use axum::routing::{get, post}; +pub use config::Config; +use guard::DestinationPolicy; +use limits::RateLimiter; +use log::{debug, 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; + +/// Minimal operational logging; request diagnostics require an explicit `RUST_LOG` setting. +pub const DEFAULT_LOG_FILTER: &str = "warn,yaak_http=error,yaak_web::startup=info"; + +#[derive(Clone)] +struct AppState { + config: Arc, + limits: Arc, + rate_limiter: Arc, + in_flight: Arc, +} + +/// Build the API router and, when configured, the web client's static-file fallback. +/// +/// The returned router owns its state and accepts normal Axum middleware via `.layer()`. +/// Construction does not parse arguments, initialize logging, bind a socket, or install +/// shutdown handlers. `config.host` and `config.port` are used only by the standalone binary. +/// +/// Serve with `into_make_service_with_connect_info::()` so the send endpoint +/// can extract the peer address for rate limiting. +pub fn router(config: Config) -> Router { + 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()); + + match &state.config.serve { + Some(dir) => { + info!("Serving the web client from {}", dir.display()); + api.merge(web_router(dir)) + } + // Without `--serve` there is no app here, only `/v1`. Someone who opens this + // port in a browser guessed wrong about which of the two dev servers hosts the + // app, so send them to the right one when we have been told where it is. + None => { + let app_port = state.config.app_port; + api.fallback(move |headers: HeaderMap| async move { no_app_here(app_port, headers) }) + } + } +} + +/// 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 +} + +/// The name in a `Host` header, without the port this server is reached on. +/// +/// An IPv6 literal keeps its brackets and its own colons: the last colon only separates a +/// port when what follows it isn't part of the address, which is what the `]` test decides. +fn host_without_port(host: &str) -> &str { + match host.rfind(':') { + Some(i) if !host[i..].contains(']') => &host[..i], + _ => host, + } +} + +/// What the send executor does with a browser that came looking for the app. +/// +/// With an app port configured this is a redirect, built from the `Host` header so the +/// hostname the browser already used is the one it keeps — this server has no idea which of +/// its addresses someone typed, and does not need to. +fn no_app_here(app_port: Option, headers: HeaderMap) -> Response { + if let Some(port) = app_port + && let Some(host) = headers.get(header::HOST).and_then(|v| v.to_str().ok()) + { + return Redirect::temporary(&format!("http://{}:{port}/", host_without_port(host))) + .into_response(); + } + + ( + StatusCode::NOT_FOUND, + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + "This is the Yaak send executor. It serves the API under /v1 and no app.\n\n\ + To serve the app from here too, restart with --serve pointing at a built\n\ + web client.\n", + ) + .into_response() +} + +fn allowed_origins(origins: &[String]) -> AllowOrigin { + if origins.iter().any(|o| o.trim() == "*") { + return AllowOrigin::any(); + } + let parsed: Vec = + origins.iter().filter_map(|o| HeaderValue::from_str(o.trim()).ok()).collect(); + AllowOrigin::list(parsed) +} + +async fn health(State(state): State) -> 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) -> 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::() + { + return ip; + } + peer.ip() +} + +async fn send_http( + State(state): State, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + Json(body): Json, +) -> Response { + let ip = client_ip(&state.config, &headers, peer); + if let Err(wait) = state.rate_limiter.check(ip) { + debug!("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 { + debug!("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)) => { + debug!("Refused send from {ip}: {m}"); + return error_response(StatusCode::FORBIDDEN, m); + } + }; + + let description = prepared.describe(); + debug!("{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( + mut rx: tokio::sync::mpsc::Receiver, +) -> impl futures_util::Stream + Send + 'static { + futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx)) +} + +#[cfg(test)] +mod tests { + use super::host_without_port; + + #[test] + fn strips_the_port_and_keeps_the_name() { + assert_eq!(host_without_port("home:9227"), "home"); + assert_eq!(host_without_port("home"), "home"); + assert_eq!(host_without_port("192.168.1.5:9227"), "192.168.1.5"); + assert_eq!(host_without_port("192.168.1.5"), "192.168.1.5"); + // An IPv6 literal is full of colons, and only the one outside the brackets is a port. + assert_eq!(host_without_port("[::1]:9227"), "[::1]"); + assert_eq!(host_without_port("[::1]"), "[::1]"); + assert_eq!(host_without_port("[2606:4700::1111]:8080"), "[2606:4700::1111]"); + assert_eq!(host_without_port("[2606:4700::1111]"), "[2606:4700::1111]"); + } +} diff --git a/crates-server/yaak-web/src/main.rs b/crates-server/yaak-web/src/main.rs index edf9c078d..a28d3e220 100644 --- a/crates-server/yaak-web/src/main.rs +++ b/crates-server/yaak-web/src/main.rs @@ -1,107 +1,29 @@ -//! 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. +//! Standalone server for Yaak in a browser. -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, - limits: Arc, - rate_limiter: Arc, - in_flight: Arc, -} +use log::info; +use std::net::SocketAddr; +use yaak_web::{Config, router}; #[tokio::main] async fn main() { - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + env_logger::Builder::from_env( + env_logger::Env::default().default_filter_or(yaak_web::DEFAULT_LOG_FILTER), + ) + .init(); let config = Config::parse(); + let bind = config.listen_addr().unwrap_or_else(|e| { + eprintln!("{e}"); + std::process::exit(1); + }); + let rate_limit_per_minute = config.rate_limit_per_minute; + let app = router(config); - 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, - ); + info!(target: "yaak_web::startup", "yaak-web listening on http://{bind} (rate limit: {}/min)", rate_limit_per_minute,); axum::serve(listener, app.into_make_service_with_connect_info::()) .with_graceful_shutdown(async { @@ -111,141 +33,3 @@ async fn main() { .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 = - origins.iter().filter_map(|o| HeaderValue::from_str(o.trim()).ok()).collect(); - AllowOrigin::list(parsed) -} - -async fn health(State(state): State) -> 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) -> 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::() - { - return ip; - } - peer.ip() -} - -async fn send_http( - State(state): State, - ConnectInfo(peer): ConnectInfo, - headers: HeaderMap, - Json(body): Json, -) -> 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( - mut rx: tokio::sync::mpsc::Receiver, -) -> impl futures_util::Stream + Send + 'static { - futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx)) -} diff --git a/crates-server/yaak-web/src/send.rs b/crates-server/yaak-web/src/send.rs index e6010c792..841d229e9 100644 --- a/crates-server/yaak-web/src/send.rs +++ b/crates-server/yaak-web/src/send.rs @@ -9,7 +9,7 @@ use crate::guard::{DestinationPolicy, GuardedSender}; use crate::wire::{Frame, SendRequest}; use base64::Engine; use bytes::Bytes; -use log::{info, warn}; +use log::{debug, warn}; use std::convert::Infallible; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -344,7 +344,8 @@ 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}"); + warn!("Failed to serialize response frame"); + debug!("Frame serialization error: {e}"); return Err(()); } }; @@ -352,7 +353,7 @@ async fn write_frame(frames: &FrameSender, frame: &Frame) -> Result<(), ()> { frames.send(Ok(Bytes::from(line))).await.map_err(|_| ()) } -/// Log a finished send at info: destination, outcome, and how long, never the content. +/// Request diagnostics are opt-in, including destinations and completion timing. pub fn log_outcome(description: &str, started: Instant, outcome: &str) { - info!("{description} -> {outcome} in {:?}", started.elapsed()); + debug!("{description} -> {outcome} in {:?}", started.elapsed()); } diff --git a/crates-server/yaak-web/tests/logging.rs b/crates-server/yaak-web/tests/logging.rs new file mode 100644 index 000000000..99b431649 --- /dev/null +++ b/crates-server/yaak-web/tests/logging.rs @@ -0,0 +1,98 @@ +use axum::Router; +use axum::body::{Body, to_bytes}; +use axum::extract::{ConnectInfo, Request}; +use axum::http::StatusCode; +use axum::routing::get; +use clap::Parser; +use std::io::{self, Write}; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use tower::ServiceExt; +use yaak_web::{Config, DEFAULT_LOG_FILTER, router}; + +#[derive(Clone)] +struct LogCapture(Arc>>); + +impl Write for LogCapture { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +fn send_request(url: &str) -> Request { + let payload = serde_json::json!({ + "request": { "url": url, "method": "GET" }, + "settings": { + "validateCertificates": true, + "followRedirects": true, + "timeoutMs": 2000, + "sendCookies": true, + "storeCookies": true + } + }); + Request::builder() + .method("POST") + .uri("/v1/http/send") + .header("content-type", "application/json") + .extension(ConnectInfo("192.0.2.42:1234".parse::().unwrap())) + .body(Body::from(payload.to_string())) + .unwrap() +} + +#[tokio::test] +async fn default_logging_keeps_operational_warnings_without_request_details() { + let capture = LogCapture(Arc::new(Mutex::new(Vec::new()))); + env_logger::Builder::new() + .parse_filters(DEFAULT_LOG_FILTER) + .target(env_logger::Target::Pipe(Box::new(capture.clone()))) + .init(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let upstream = tokio::spawn(async move { + axum::serve( + listener, + Router::new().route("/private-path", get(|| async { "secret body" })), + ) + .await + .unwrap(); + }); + let mut config = Config::parse_from(["yaak-web"]); + config.allow_private_networks = true; + config.rate_limit_per_minute = 1; + let app = router(config.clone()); + assert!(String::from_utf8_lossy(&capture.0.lock().unwrap()).contains("ALLOWED")); + capture.0.lock().unwrap().clear(); + + // Exercise the HTTP engine and DNS lookup as well as the proxy's own logs. + let url = format!("http://localhost:{port}/private-path?token=secret"); + let response = app.clone().oneshot(send_request(&url)).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap(); + assert!(String::from_utf8_lossy(&body).contains("\"type\":\"done\"")); + assert_eq!( + app.oneshot(send_request(&url)).await.unwrap().status(), + StatusCode::TOO_MANY_REQUESTS + ); + + config.allow_private_networks = false; + let app = router(config.clone()); + assert_eq!( + app.oneshot(send_request("http://127.0.0.1/private-path")).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + config.max_concurrent = 0; + assert_eq!( + router(config).oneshot(send_request(&url)).await.unwrap().status(), + StatusCode::SERVICE_UNAVAILABLE + ); + + upstream.abort(); + let logs = capture.0.lock().unwrap(); + assert!(logs.is_empty(), "Unexpected request logs: {}", String::from_utf8_lossy(&logs)); +} diff --git a/crates-server/yaak-web/tests/router.rs b/crates-server/yaak-web/tests/router.rs new file mode 100644 index 000000000..c18f4963d --- /dev/null +++ b/crates-server/yaak-web/tests/router.rs @@ -0,0 +1,89 @@ +use axum::body::{Body, to_bytes}; +use axum::extract::Request; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::middleware::{self, Next}; +use axum::response::Response; +use std::path::PathBuf; +use tower::ServiceExt; +use yaak_web::{Config, router}; + +fn config(serve: Option) -> Config { + Config { + host: "127.0.0.1".to_string(), + port: 0, + app_port: None, + serve, + allow_private_networks: false, + allowed_origins: vec!["*".into()], + max_request_bytes: 1024, + max_response_bytes: 2048, + max_timeout_secs: 5, + rate_limit_per_minute: 10, + max_concurrent: 2, + trust_forwarded_for: false, + } +} + +async fn mark_response(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + response.headers_mut().insert("x-hosted-middleware", HeaderValue::from_static("yes")); + response +} + +#[tokio::test] +async fn embedded_router_serves_api_without_static_files() { + let app = router(config(None)).layer(middleware::from_fn(mark_response)); + let response = app + .clone() + .oneshot(Request::builder().uri("/v1/health").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["x-hosted-middleware"], "yes"); + let body = to_bytes(response.into_body(), 4096).await.unwrap(); + let health: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(health["ok"], true); + assert_eq!(health["maxResponseBytes"], 2048); + assert_eq!(health["maxTimeoutSecs"], 5); + + let response = app.oneshot(Request::new(Body::empty())).await.unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn middleware_wraps_html_fallback_and_assets_alongside_api() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("index.html"), "Yaak").unwrap(); + std::fs::create_dir(dir.path().join("assets")).unwrap(); + std::fs::write(dir.path().join("assets/app-abc.js"), "// app").unwrap(); + let app = router(config(Some(dir.path().into()))).layer(middleware::from_fn(mark_response)); + + for path in [ + "/", + "/workspace/example", + "/assets/app-abc.js", + "/v1/health", + ] { + let response = app + .clone() + .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{path}"); + assert_eq!(response.headers()["x-hosted-middleware"], "yes", "{path}"); + if path.starts_with("/assets/") { + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "public, max-age=31536000, immutable" + ); + } else if path == "/v1/health" { + assert_eq!(response.headers()[header::CONTENT_TYPE], "application/json"); + } else { + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache"); + assert!( + response.headers()[header::CONTENT_TYPE].to_str().unwrap().starts_with("text/html") + ); + assert_eq!(to_bytes(response.into_body(), 4096).await.unwrap(), "Yaak"); + } + } +} diff --git a/crates-tauri/yaak-app-client/Cargo.toml b/crates-tauri/yaak-app-client/Cargo.toml index 0b4840769..5465d98cd 100644 --- a/crates-tauri/yaak-app-client/Cargo.toml +++ b/crates-tauri/yaak-app-client/Cargo.toml @@ -28,7 +28,7 @@ openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu inst rlimit = "0.11" # Raise the launchd 256 open-file soft limit at startup [dependencies] -base64 = "0.22.1" # For writing values the editor only ever held encoded +base64 = "0.23.1" # For writing values the editor only ever held encoded charset = "0.1.5" chrono = { workspace = true, features = ["serde"] } cookie = "0.18.1" @@ -41,14 +41,15 @@ pretty_graphql = "0.2" r2d2 = "0.8.10" r2d2_sqlite = "0.32" mime_guess = "2.0.5" -rand = "0.9.0" +rand = "0.10.2" reqwest = { workspace = true, features = [ "multipart", "gzip", "brotli", "deflate", "json", - "rustls-tls-manual-roots-no-provider", + "query", + "rustls-no-provider", "socks", "http2", ] } @@ -68,7 +69,7 @@ tauri-plugin-updater = "2.10.1" thiserror = { workspace = true } tokio = { workspace = true, features = ["sync"] } tokio-stream = "0.1.17" -tokio-tungstenite = { version = "0.26.2", default-features = false } +tokio-tungstenite = { version = "0.30.0", default-features = false } url = "2" tokio-util = { version = "0.7", features = ["codec"] } ts-rs = { workspace = true } @@ -82,6 +83,7 @@ yaak-core = { workspace = true } yaak = { workspace = true } yaak-commands = { workspace = true } yaak-crypto = { workspace = true } +yaak-filter = { workspace = true } yaak-fonts = { workspace = true } yaak-git = { workspace = true } yaak-grpc = { workspace = true } @@ -89,6 +91,7 @@ yaak-http = { workspace = true } yaak-license = { workspace = true, optional = true } yaak-mac-window = { workspace = true } yaak-lifecycle = { workspace = true } +yaak-mcp = { workspace = true } yaak-models = { workspace = true } yaak-plugins = { workspace = true } yaak-sse = { workspace = true } diff --git a/crates-tauri/yaak-app-client/bindings/index.ts b/crates-tauri/yaak-app-client/bindings/index.ts index 6fb02079c..55e7db086 100644 --- a/crates-tauri/yaak-app-client/bindings/index.ts +++ b/crates-tauri/yaak-app-client/bindings/index.ts @@ -1,5 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +export type NavigateWorkspace = { workspaceId: string; environmentId: string | null }; + export type PluginUpdateInfo = { name: string; currentVersion: string; latestVersion: string }; export type PluginUpdateNotification = { updateCount: number; plugins: Array }; diff --git a/crates-tauri/yaak-app-client/src/grpc.rs b/crates-tauri/yaak-app-client/src/grpc.rs index 52add513e..2829ae429 100644 --- a/crates-tauri/yaak-app-client/src/grpc.rs +++ b/crates-tauri/yaak-app-client/src/grpc.rs @@ -19,7 +19,6 @@ pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap entries } - pub(crate) async fn build_metadata( window: &WebviewWindow, request: &GrpcRequest, diff --git a/crates-tauri/yaak-app-client/src/http_request.rs b/crates-tauri/yaak-app-client/src/http_request.rs index 6e010d9a6..b8a63b9c8 100644 --- a/crates-tauri/yaak-app-client/src/http_request.rs +++ b/crates-tauri/yaak-app-client/src/http_request.rs @@ -177,4 +177,3 @@ async fn send_http_request_inner( Ok(SentHttpRequest { response: result.response, body: result.response_body }) } - diff --git a/crates-tauri/yaak-app-client/src/import.rs b/crates-tauri/yaak-app-client/src/import.rs index 127f3eecd..19cc8e320 100644 --- a/crates-tauri/yaak-app-client/src/import.rs +++ b/crates-tauri/yaak-app-client/src/import.rs @@ -1,46 +1,89 @@ use crate::PluginContextExt; use crate::error::{Error, Result}; use crate::models_ext::QueryManagerExt; -use std::fs::read_to_string; -use std::io::ErrorKind; +use std::path::Path; use tauri::{Manager, Runtime, WebviewWindow}; use yaak::import::{self, PlanImportDataParams}; use yaak_api::{ApiClientKind, yaak_api_client}; use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlan}; +use yaak_plugins::MAX_IMPORT_BYTES; +use yaak_plugins::events::ImportRequest; pub(crate) async fn import_data( window: &WebviewWindow, file_path: &str, origin: Option, ) -> Result { - let contents = read_import_file(file_path)?; + let input = read_import_file(file_path)?; let plan = - plan_import_contents(window, &contents, ImportDestination::NewWorkspace, origin).await?; + plan_import_contents(window, &input, ImportDestination::NewWorkspace, origin).await?; commit_import(window, plan) } pub(crate) async fn plan_import_data( window: &WebviewWindow, - file_path: &str, + file_paths: &[String], + urls: &[String], destination: ImportDestination, ) -> Result { - let contents = read_import_file(file_path)?; - plan_import_contents(window, &contents, destination, Some(file_origin(file_path))).await + let plugin_manager = crate::plugins_ext::plugin_manager(window).await?; + let plugin_context = window.plugin_context(); + let mut seen = std::collections::BTreeSet::new(); + let mut inputs = Vec::new(); + for path in file_paths { + let origin = file_origin(path); + if !seen.insert(origin.origin.clone()) { + continue; + } + let input = read_import_file(path)?; + let response = plugin_manager + .import_input(&plugin_context, &input) + .await + .map_err(|err| Error::GenericError(format!("Unable to import {path}: {err}")))?; + inputs.push((response, origin)); + } + for url in urls { + let url = normalize_import_url(url)?; + if !seen.insert(url.clone()) { + continue; + } + let input = fetch_import_url(window, &url).await?; + let response = plugin_manager + .import_input(&plugin_context, &input) + .await + .map_err(|err| Error::GenericError(format!("Unable to import {url}: {err}")))?; + inputs.push((response, url_origin(&url))); + } + Ok(import::plan_import_batch_resources(&window.db_manager(), destination, inputs)?) } -pub(crate) async fn plan_import_url( +/// Which importer claims a source, without planning it. Backs the per-source status in the +/// import dialog so unsupported files are flagged before preview. +pub(crate) async fn detect_import_source( window: &WebviewWindow, - url: &str, - destination: ImportDestination, -) -> Result { - 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 + file_path: Option, + url: Option, +) -> Result { + let input = match (file_path, url) { + (Some(path), _) => read_import_file(&path)?, + (None, Some(url)) => fetch_import_url(window, &normalize_import_url(&url)?).await?, + (None, None) => return Err(Error::GenericError("Nothing to detect".into())), + }; + let plugin_manager = crate::plugins_ext::plugin_manager(window).await?; + let response = plugin_manager.import_input(&window.plugin_context(), &input).await.map_err( + |err| match err { + yaak_plugins::error::Error::PluginErr(msg) if msg.starts_with("No importers found") => { + Error::GenericError("Not a supported format".into()) + } + err => Error::GenericError(err.to_string()), + }, + )?; + Ok(response.importer) } async fn plan_import_contents( window: &WebviewWindow, - contents: &str, + input: &ImportRequest, destination: ImportDestination, origin: Option, ) -> Result { @@ -53,7 +96,7 @@ async fn plan_import_contents( plugin_manager: &plugin_manager, plugin_context: &plugin_context, destination, - contents, + input, origin, }) .await?) @@ -88,7 +131,10 @@ pub(crate) fn commit_import( /// This uses Yaak's own API client, which follows the OS proxy but not the workspace's proxy, /// client certificate, or certificate-validation settings. Requests are unauthenticated, so /// specs behind auth must still be downloaded manually and imported as a file. -async fn fetch_import_url(window: &WebviewWindow, url: &str) -> Result { +async fn fetch_import_url( + window: &WebviewWindow, + url: &str, +) -> Result { let url = normalize_import_url(url)?; let app_version = window.app_handle().package_info().version.to_string(); let response = yaak_api_client(ApiClientKind::App, &app_version)? @@ -104,10 +150,27 @@ async fn fetch_import_url(window: &WebviewWindow, url: &str) -> R return Err(Error::GenericError(format!("Failed to fetch {url}: responded with {status}"))); } - response - .text() + // Apply the same input-size limit to downloads before allocating the whole body. + let mut response = response; + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() .await - .map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}"))) + .map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}")))? + { + if (bytes.len() + chunk.len()) as u64 > MAX_IMPORT_BYTES { + return Err(Error::GenericError("Import file exceeds the 64 MiB limit".into())); + } + bytes.extend_from_slice(&chunk); + } + let name = reqwest::Url::parse(&url) + .ok() + .and_then(|url| { + url.path_segments().and_then(|mut segments| segments.next_back().map(str::to_owned)) + }) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "download".into()); + ImportRequest::from_bytes(&name, &bytes).map_err(|err| Error::GenericError(err.to_string())) } pub(crate) fn normalize_import_url(url: &str) -> Result { @@ -128,15 +191,9 @@ pub(crate) fn normalize_import_url(url: &str) -> Result { } } -fn read_import_file(file_path: &str) -> Result { - read_to_string(file_path).map_err(|err| { - if err.kind() == ErrorKind::InvalidData { - Error::GenericError(format!( - "Import file must be UTF-8 text; binary files are not supported: {file_path}" - )) - } else { - Error::GenericError(format!("Unable to read import file {file_path}: {err}")) - } +fn read_import_file(file_path: &str) -> Result { + ImportRequest::from_path(Path::new(file_path)).map_err(|err| { + Error::GenericError(format!("Unable to read import source {file_path}: {err}")) }) } @@ -147,7 +204,7 @@ mod tests { use std::time::{SystemTime, UNIX_EPOCH}; #[test] - fn read_import_file_returns_error_for_binary_file() { + fn read_import_file_accepts_binary_file() { let path = std::env::temp_dir().join(format!( "yaak-import-binary-{}.pftrace", SystemTime::now() @@ -157,10 +214,11 @@ mod tests { )); write(&path, [0xff, 0xfe, 0xfd]).expect("write binary fixture"); - let err = read_import_file(path.to_str().expect("temp path is utf-8")) - .expect_err("binary import should return an error"); + let input = read_import_file(path.to_str().expect("temp path is utf-8")) + .expect("binary import should preserve bytes"); - assert!(err.to_string().contains("binary files are not supported")); + assert!(input.source.is_some()); + assert!(input.content.is_empty()); remove_file(path).expect("remove binary fixture"); } diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs index fabe1ce06..395173558 100644 --- a/crates-tauri/yaak-app-client/src/lib.rs +++ b/crates-tauri/yaak-app-client/src/lib.rs @@ -4,7 +4,7 @@ use crate::error::Error::GenericError; use crate::error::Result; use crate::grpc::{build_metadata, metadata_to_map}; use crate::http_request::send_http_request; -use crate::import::{commit_import, plan_import_data, plan_import_url}; +use crate::import::{commit_import, plan_import_data}; use crate::models_ext::{BlobManagerExt, QueryManagerExt}; use crate::notifications::YaakNotifier; use crate::render::{render_grpc_request, render_template}; @@ -29,16 +29,16 @@ use tokio::sync::Mutex; use tokio::task::block_in_place; use tokio::time; use yaak::send::ResponseBody; -use yaak_commands::responses::locate_response_body; use yaak_commands::resolve::resolve_grpc_request; +use yaak_commands::responses::locate_response_body; 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::{ - CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent, - GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace, + CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent, GrpcEventType, + HttpRequest, HttpResponse, HttpResponseState, Workspace, }; use yaak_models::queries::any_request::AnyRequest; use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, UpdateSource}; @@ -138,6 +138,7 @@ fn setup_window_menu(win: &WebviewWindow) -> Result<()> { "dev.generate_theme_css" => { w.emit("generate_theme_css", true).unwrap(); } + "dev.show_home" => w.emit("show_home", true).unwrap(), "dev.toggle_devtools" => { if webview_window.is_devtools_open() { webview_window.close_devtools(); @@ -331,25 +332,25 @@ async fn cmd_grpc_go( let settings = app_handle.db().get_settings(); let client_cert = find_client_certificate(&request.url, &settings.client_certificates); - // Capture the stored request, not the rendered one: what a restore should - // put back is what the user typed - let version_id = app_handle - .db() - .snapshot_request_for_send(&AnyRequest::GrpcRequest(unrendered_request.clone())); - - let conn = app_handle.db().upsert_grpc_connection( - &GrpcConnection { - workspace_id: request.workspace_id.clone(), - request_id: request.id.clone(), - status: -1, - elapsed: 0, - state: GrpcConnectionState::Initialized, - url: request.url.clone(), - version_id, - ..Default::default() - }, - &UpdateSource::from_window_label(window.label()), - )?; + let conn = app_handle.with_tx(|tx| { + // Capture the stored request, not the rendered one: what a restore should + // put back is what the user typed + let version_id = + tx.snapshot_request_for_send(&AnyRequest::GrpcRequest(unrendered_request.clone())); + tx.upsert_grpc_connection( + &GrpcConnection { + workspace_id: request.workspace_id.clone(), + request_id: request.id.clone(), + status: -1, + elapsed: 0, + state: GrpcConnectionState::Initialized, + url: request.url.clone(), + version_id, + ..Default::default() + }, + &UpdateSource::from_window_label(window.label()), + ) + })?; let conn_id = conn.id.clone(); @@ -394,15 +395,17 @@ async fn cmd_grpc_go( let connection = match connection { Ok(c) => c, Err(err) => { - app_handle.db().upsert_grpc_connection( - &GrpcConnection { - elapsed: start.elapsed().as_millis() as i32, - error: Some(err.to_string()), - state: GrpcConnectionState::Closed, - ..conn.clone() - }, - &UpdateSource::from_window_label(window.label()), - )?; + app_handle.with_tx(|tx| { + tx.upsert_grpc_connection( + &GrpcConnection { + elapsed: start.elapsed().as_millis() as i32, + error: Some(err.to_string()), + state: GrpcConnectionState::Closed, + ..conn.clone() + }, + &UpdateSource::from_window_label(window.label()), + ) + })?; return Ok(conn_id); } }; @@ -503,15 +506,17 @@ async fn cmd_grpc_go( .await?; let msg = strip_json_comments(&msg); - app_handle.db().upsert_grpc_event( - &GrpcEvent { - content: format!("Connecting to {}", req.url), - event_type: GrpcEventType::ConnectionStart, - metadata: metadata.clone(), - ..base_event.clone() - }, - &UpdateSource::from_window_label(window.label()), - )?; + app_handle.with_tx(|tx| { + tx.upsert_grpc_event( + &GrpcEvent { + content: format!("Connecting to {}", req.url), + event_type: GrpcEventType::ConnectionStart, + metadata: metadata.clone(), + ..base_event.clone() + }, + &UpdateSource::from_window_label(window.label()), + ) + })?; async move { // Create callback for streaming methods that handles both success and error @@ -521,24 +526,28 @@ async fn cmd_grpc_go( let window_label = window.label().to_string(); move |result: std::result::Result| match result { Ok(msg) => { - let _ = app_handle.db().upsert_grpc_event( - &GrpcEvent { - content: msg, - event_type: GrpcEventType::ClientMessage, - ..base_event.clone() - }, - &UpdateSource::from_window_label(&window_label), - ); + let _ = app_handle.with_tx(|tx| { + tx.upsert_grpc_event( + &GrpcEvent { + content: msg, + event_type: GrpcEventType::ClientMessage, + ..base_event.clone() + }, + &UpdateSource::from_window_label(&window_label), + ) + }); } Err(error) => { - let _ = app_handle.db().upsert_grpc_event( - &GrpcEvent { - content: format!("Failed to send message: {}", error), - event_type: GrpcEventType::Error, - ..base_event.clone() - }, - &UpdateSource::from_window_label(&window_label), - ); + let _ = app_handle.with_tx(|tx| { + tx.upsert_grpc_event( + &GrpcEvent { + content: format!("Failed to send message: {}", error), + event_type: GrpcEventType::Error, + ..base_event.clone() + }, + &UpdateSource::from_window_label(&window_label), + ) + }); } } }; @@ -591,36 +600,38 @@ async fn cmd_grpc_go( if !method_desc.is_client_streaming() { app_handle - .db() - .upsert_grpc_event( - &GrpcEvent { - event_type: GrpcEventType::ClientMessage, - content: msg, - ..base_event.clone() - }, - &UpdateSource::from_window_label(window.label()), - ) + .with_tx(|tx| { + tx.upsert_grpc_event( + &GrpcEvent { + event_type: GrpcEventType::ClientMessage, + content: msg, + ..base_event.clone() + }, + &UpdateSource::from_window_label(window.label()), + ) + }) .unwrap(); } match maybe_msg { Some(Ok(msg)) => { app_handle - .db() - .upsert_grpc_event( - &GrpcEvent { - metadata: metadata_to_map(msg.metadata().clone()), - content: if msg.metadata().len() == 0 { - "Received response" - } else { - "Received response with metadata" - } - .to_string(), - event_type: GrpcEventType::Info, - ..base_event.clone() - }, - &UpdateSource::from_window_label(window.label()), - ) + .with_tx(|tx| { + tx.upsert_grpc_event( + &GrpcEvent { + metadata: metadata_to_map(msg.metadata().clone()), + content: if msg.metadata().len() == 0 { + "Received response" + } else { + "Received response with metadata" + } + .to_string(), + event_type: GrpcEventType::Info, + ..base_event.clone() + }, + &UpdateSource::from_window_label(window.label()), + ) + }) .unwrap(); let response_message = msg.into_inner(); let content = match connection @@ -630,83 +641,88 @@ async fn cmd_grpc_go( Ok(content) => content, Err(err) => { app_handle - .db() - .upsert_grpc_event( - &GrpcEvent { - content: "Failed to read response".to_string(), - error: Some(err.to_string()), - status: Some(Code::Internal as i32), - event_type: GrpcEventType::ConnectionEnd, - ..base_event.clone() - }, - &UpdateSource::from_window_label(window.label()), - ) + .with_tx(|tx| { + tx.upsert_grpc_event( + &GrpcEvent { + content: "Failed to read response".to_string(), + error: Some(err.to_string()), + status: Some(Code::Internal as i32), + event_type: GrpcEventType::ConnectionEnd, + ..base_event.clone() + }, + &UpdateSource::from_window_label(window.label()), + ) + }) .unwrap(); return; } }; app_handle - .db() - .upsert_grpc_event( - &GrpcEvent { - content, - event_type: GrpcEventType::ServerMessage, - ..base_event.clone() - }, - &UpdateSource::from_window_label(window.label()), - ) + .with_tx(|tx| { + tx.upsert_grpc_event( + &GrpcEvent { + content, + event_type: GrpcEventType::ServerMessage, + ..base_event.clone() + }, + &UpdateSource::from_window_label(window.label()), + ) + }) .unwrap(); app_handle - .db() - .upsert_grpc_event( - &GrpcEvent { - content: "Connection complete".to_string(), - event_type: GrpcEventType::ConnectionEnd, - status: Some(Code::Ok as i32), - ..base_event.clone() - }, - &UpdateSource::from_window_label(window.label()), - ) + .with_tx(|tx| { + tx.upsert_grpc_event( + &GrpcEvent { + content: "Connection complete".to_string(), + event_type: GrpcEventType::ConnectionEnd, + status: Some(Code::Ok as i32), + ..base_event.clone() + }, + &UpdateSource::from_window_label(window.label()), + ) + }) .unwrap(); } Some(Err(yaak_grpc::error::Error::GrpcStreamError(e))) => { app_handle - .db() - .upsert_grpc_event( - &(match e.status { - Some(s) => GrpcEvent { - error: Some(s.message().to_string()), - status: Some(s.code() as i32), - content: "Request failed".to_string(), - metadata: metadata_to_map(s.metadata().clone()), - event_type: GrpcEventType::ConnectionEnd, - ..base_event.clone() - }, - None => GrpcEvent { - error: Some(e.message), + .with_tx(|tx| { + tx.upsert_grpc_event( + &(match e.status { + Some(s) => GrpcEvent { + error: Some(s.message().to_string()), + status: Some(s.code() as i32), + content: "Request failed".to_string(), + metadata: metadata_to_map(s.metadata().clone()), + event_type: GrpcEventType::ConnectionEnd, + ..base_event.clone() + }, + None => GrpcEvent { + error: Some(e.message), + status: Some(Code::Unknown as i32), + content: "Request failed".to_string(), + event_type: GrpcEventType::ConnectionEnd, + ..base_event.clone() + }, + }), + &UpdateSource::from_window_label(window.label()), + ) + }) + .unwrap(); + } + Some(Err(e)) => { + app_handle + .with_tx(|tx| { + tx.upsert_grpc_event( + &GrpcEvent { + error: Some(e.to_string()), status: Some(Code::Unknown as i32), content: "Request failed".to_string(), event_type: GrpcEventType::ConnectionEnd, ..base_event.clone() }, - }), - &UpdateSource::from_window_label(window.label()), - ) - .unwrap(); - } - Some(Err(e)) => { - app_handle - .db() - .upsert_grpc_event( - &GrpcEvent { - error: Some(e.to_string()), - status: Some(Code::Unknown as i32), - content: "Request failed".to_string(), - event_type: GrpcEventType::ConnectionEnd, - ..base_event.clone() - }, - &UpdateSource::from_window_label(window.label()), - ) + &UpdateSource::from_window_label(window.label()), + ) + }) .unwrap(); } None => { @@ -717,64 +733,67 @@ async fn cmd_grpc_go( let mut stream = match maybe_stream { Some(Ok(stream)) => { app_handle - .db() - .upsert_grpc_event( - &GrpcEvent { - metadata: metadata_to_map(stream.metadata().clone()), - content: if stream.metadata().len() == 0 { - "Received response" - } else { - "Received response with metadata" - } - .to_string(), - event_type: GrpcEventType::Info, - ..base_event.clone() - }, - &UpdateSource::from_window_label(window.label()), - ) + .with_tx(|tx| { + tx.upsert_grpc_event( + &GrpcEvent { + metadata: metadata_to_map(stream.metadata().clone()), + content: if stream.metadata().len() == 0 { + "Received response" + } else { + "Received response with metadata" + } + .to_string(), + event_type: GrpcEventType::Info, + ..base_event.clone() + }, + &UpdateSource::from_window_label(window.label()), + ) + }) .unwrap(); stream.into_inner() } Some(Err(yaak_grpc::error::Error::GrpcStreamError(e))) => { warn!("GRPC stream error {e:?}"); app_handle - .db() - .upsert_grpc_event( - &(match e.status { - Some(s) => GrpcEvent { - error: Some(s.message().to_string()), - status: Some(s.code() as i32), - content: "Stream failed".to_string(), - metadata: metadata_to_map(s.metadata().clone()), - event_type: GrpcEventType::ConnectionEnd, - ..base_event.clone() - }, - None => GrpcEvent { - error: Some(e.message), - status: Some(Code::Unknown as i32), - content: "Stream failed".to_string(), - event_type: GrpcEventType::ConnectionEnd, - ..base_event.clone() - }, - }), - &UpdateSource::from_window_label(window.label()), - ) + .with_tx(|tx| { + tx.upsert_grpc_event( + &(match e.status { + Some(s) => GrpcEvent { + error: Some(s.message().to_string()), + status: Some(s.code() as i32), + content: "Stream failed".to_string(), + metadata: metadata_to_map(s.metadata().clone()), + event_type: GrpcEventType::ConnectionEnd, + ..base_event.clone() + }, + None => GrpcEvent { + error: Some(e.message), + status: Some(Code::Unknown as i32), + content: "Stream failed".to_string(), + event_type: GrpcEventType::ConnectionEnd, + ..base_event.clone() + }, + }), + &UpdateSource::from_window_label(window.label()), + ) + }) .unwrap(); return; } Some(Err(e)) => { app_handle - .db() - .upsert_grpc_event( - &GrpcEvent { - error: Some(e.to_string()), - status: Some(Code::Unknown as i32), - content: "Stream failed".to_string(), - event_type: GrpcEventType::ConnectionEnd, - ..base_event.clone() - }, - &UpdateSource::from_window_label(window.label()), - ) + .with_tx(|tx| { + tx.upsert_grpc_event( + &GrpcEvent { + error: Some(e.to_string()), + status: Some(Code::Unknown as i32), + content: "Stream failed".to_string(), + event_type: GrpcEventType::ConnectionEnd, + ..base_event.clone() + }, + &UpdateSource::from_window_label(window.label()), + ) + }) .unwrap(); return; } @@ -791,65 +810,69 @@ async fn cmd_grpc_go( Ok(message) => message, Err(err) => { app_handle - .db() - .upsert_grpc_event( - &GrpcEvent { - content: "Failed to read response".to_string(), - error: Some(err.to_string()), - status: Some(Code::Internal as i32), - event_type: GrpcEventType::ConnectionEnd, - ..base_event.clone() - }, - &UpdateSource::from_window_label(window.label()), - ) + .with_tx(|tx| { + tx.upsert_grpc_event( + &GrpcEvent { + content: "Failed to read response".to_string(), + error: Some(err.to_string()), + status: Some(Code::Internal as i32), + event_type: GrpcEventType::ConnectionEnd, + ..base_event.clone() + }, + &UpdateSource::from_window_label(window.label()), + ) + }) .unwrap(); break; } }; app_handle - .db() - .upsert_grpc_event( - &GrpcEvent { - content: message, - event_type: GrpcEventType::ServerMessage, - ..base_event.clone() - }, - &UpdateSource::from_window_label(window.label()), - ) + .with_tx(|tx| { + tx.upsert_grpc_event( + &GrpcEvent { + content: message, + event_type: GrpcEventType::ServerMessage, + ..base_event.clone() + }, + &UpdateSource::from_window_label(window.label()), + ) + }) .unwrap(); } Ok(None) => { let trailers = stream.trailers().await.unwrap_or_default().unwrap_or_default(); app_handle - .db() - .upsert_grpc_event( - &GrpcEvent { - content: "Connection complete".to_string(), - status: Some(Code::Ok as i32), - metadata: metadata_to_map(trailers), - event_type: GrpcEventType::ConnectionEnd, - ..base_event.clone() - }, - &UpdateSource::from_window_label(window.label()), - ) + .with_tx(|tx| { + tx.upsert_grpc_event( + &GrpcEvent { + content: "Connection complete".to_string(), + status: Some(Code::Ok as i32), + metadata: metadata_to_map(trailers), + event_type: GrpcEventType::ConnectionEnd, + ..base_event.clone() + }, + &UpdateSource::from_window_label(window.label()), + ) + }) .unwrap(); break; } Err(status) => { app_handle - .db() - .upsert_grpc_event( - &GrpcEvent { - content: "Stream failed".to_string(), - error: Some(status.message().to_string()), - status: Some(status.code() as i32), - metadata: metadata_to_map(status.metadata().clone()), - event_type: GrpcEventType::ConnectionEnd, - ..base_event.clone() - }, - &UpdateSource::from_window_label(window.label()), - ) + .with_tx(|tx| { + tx.upsert_grpc_event( + &GrpcEvent { + content: "Stream failed".to_string(), + error: Some(status.message().to_string()), + status: Some(status.code() as i32), + metadata: metadata_to_map(status.metadata().clone()), + event_type: GrpcEventType::ConnectionEnd, + ..base_event.clone() + }, + &UpdateSource::from_window_label(window.label()), + ) + }) .unwrap(); break; } @@ -882,21 +905,24 @@ async fn cmd_grpc_go( }).unwrap(); }, _ = cancelled_rx.changed() => { - w.db().upsert_grpc_event( - &GrpcEvent { - content: "Cancelled".to_string(), - event_type: GrpcEventType::ConnectionEnd, - status: Some(Code::Cancelled as i32), - ..base_msg.clone() - }, - &UpdateSource::from_window_label(window.label()), - ).unwrap(); + w.with_tx(|tx| { + tx.upsert_grpc_event( + &GrpcEvent { + content: "Cancelled".to_string(), + event_type: GrpcEventType::ConnectionEnd, + status: Some(Code::Cancelled as i32), + ..base_msg.clone() + }, + &UpdateSource::from_window_label(window.label()), + ) + }) + .unwrap(); w.with_tx(|c| { c.upsert_grpc_connection( &GrpcConnection{ - elapsed: start.elapsed().as_millis() as i32, - status: Code::Cancelled as i32, - state: GrpcConnectionState::Closed, + elapsed: start.elapsed().as_millis() as i32, + status: Code::Cancelled as i32, + state: GrpcConnectionState::Closed, ..c.get_grpc_connection( &conn_id).unwrap().clone() }, &UpdateSource::from_window_label(window.label()), @@ -983,10 +1009,11 @@ async fn cmd_http_response_body( .ok_or(GenericError("Failed to find response body".to_string()))?; match filter { - Some(filter) if !filter.is_empty() => Ok(plugins_ext::plugin_manager(&window) - .await? - .filter_data(&window.plugin_context(), filter, &body, content_type) - .await?), + Some(filter) if !filter.is_empty() => { + let language = yaak_filter::Language::for_content_type(content_type); + let result = yaak_filter::filter(language, &body, filter); + Ok(FilterResponse { content: result.content, error: result.error }) + } _ => Ok(FilterResponse { content: body, error: None }), } } @@ -1020,18 +1047,11 @@ async fn cmd_get_sse_events( async fn cmd_import_data( window: WebviewWindow, - file_path: &str, + file_paths: &[String], + urls: &[String], destination: ImportDestination, ) -> YaakResult { - plan_import_data(&window, file_path, destination).await -} - -async fn cmd_import_url( - window: WebviewWindow, - url: &str, - destination: ImportDestination, -) -> YaakResult { - plan_import_url(&window, url, destination).await + plan_import_data(&window, file_paths, urls, destination).await } async fn cmd_commit_import( @@ -1041,20 +1061,6 @@ async fn cmd_commit_import( commit_import(&window, plan) } - - - - - - - - - - - - - - /// Decodes base64 and writes the bytes to a file the user picked. /// /// The webview can't do this itself: its `fs` permissions are read-only and scoped to the app @@ -1088,15 +1094,17 @@ async fn cmd_send_http_request( let request = app_handle.db().get_http_request(&request_id)?; let blobs = app_handle.blob_manager(); - let response = app_handle.db().upsert_http_response( - &HttpResponse { - request_id: request.id.clone(), - workspace_id: request.workspace_id.clone(), - ..Default::default() - }, - &UpdateSource::from_window_label(window.label()), - &blobs, - )?; + let response = app_handle.with_tx(|tx| { + tx.upsert_http_response( + &HttpResponse { + request_id: request.id.clone(), + workspace_id: request.workspace_id.clone(), + ..Default::default() + }, + &UpdateSource::from_window_label(window.label()), + &blobs, + ) + })?; let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false); app_handle.listen_any(format!("cancel_http_response_{}", response.id), move |_event| { @@ -1134,22 +1142,23 @@ async fn cmd_send_http_request( Ok(sent) => sent.response, Err(e) => { let resp = app_handle.db().get_http_response(&response.id)?; - app_handle.db().upsert_http_response( - &HttpResponse { - state: HttpResponseState::Closed, - error: Some(e.to_string()), - ..resp - }, - &UpdateSource::from_window_label(window.label()), - &blobs, - )? + app_handle.with_tx(|tx| { + tx.upsert_http_response( + &HttpResponse { + state: HttpResponseState::Closed, + error: Some(e.to_string()), + ..resp + }, + &UpdateSource::from_window_label(window.label()), + &blobs, + ) + })? } }; Ok(r) } - async fn cmd_new_child_window( parent_window: WebviewWindow, url: &str, @@ -1277,8 +1286,8 @@ pub fn run() { .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()) + if let Err(e) = app + .with_tx(|tx| yaak_lifecycle::on_launch(&lifecycle_host, tx, &app.blob_manager())) { error!("on_launch hook failed: {e:?}"); } diff --git a/crates-tauri/yaak-app-client/src/models_ext.rs b/crates-tauri/yaak-app-client/src/models_ext.rs index 835547dd0..5243c26a9 100644 --- a/crates-tauri/yaak-app-client/src/models_ext.rs +++ b/crates-tauri/yaak-app-client/src/models_ext.rs @@ -10,7 +10,7 @@ use tauri::plugin::TauriPlugin; use tauri::{Emitter, Manager, Runtime, State}; use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; use yaak_models::blob_manager::BlobManager; -use yaak_models::client_db::ClientDb; +use yaak_models::client_db::{ClientDb, WriteDb}; use yaak_models::error::Result; use yaak_models::query_manager::QueryManager; use yaak_models::util::{ModelPayload, UpdateSource}; @@ -95,7 +95,7 @@ pub trait QueryManagerExt<'a, R> { fn db(&'a self) -> ClientDb<'a>; fn with_tx(&'a self, func: F) -> Result where - F: FnOnce(&ClientDb) -> Result; + F: FnOnce(&WriteDb) -> Result; } impl<'a, R: Runtime, M: Manager> QueryManagerExt<'a, R> for M { @@ -110,7 +110,7 @@ impl<'a, R: Runtime, M: Manager> QueryManagerExt<'a, R> for M { fn with_tx(&'a self, func: F) -> Result where - F: FnOnce(&ClientDb) -> Result, + F: FnOnce(&WriteDb) -> Result, { let qm = self.state::(); qm.inner().with_tx(func) @@ -168,8 +168,10 @@ pub fn init() -> TauriPlugin { // Fast path for local app writes initiated by frontend windows. This keeps the // current sync-model UX snappy, while DB polling handles external writers (CLI). + // A thread of its own: `recv` blocks, and a runtime worker parked in it strands + // anything spawned from the loop in that worker's unstealable LIFO slot. let app_handle_local = app_handle.clone(); - tauri::async_runtime::spawn(async move { + std::thread::spawn(move || { while let Ok(payload) = rx.recv() { let mut batch: Vec = Vec::new(); if matches!(payload.update_source, UpdateSource::Window { .. }) { diff --git a/crates-tauri/yaak-app-client/src/notifications.rs b/crates-tauri/yaak-app-client/src/notifications.rs index c653c55e4..829afe42b 100644 --- a/crates-tauri/yaak-app-client/src/notifications.rs +++ b/crates-tauri/yaak-app-client/src/notifications.rs @@ -55,12 +55,15 @@ impl YaakNotifier { seen.push(id.to_string()); debug!("Marked notification as seen {}", id); let seen_json = serde_json::to_string(&seen)?; - window.db().set_key_value_raw( - KV_NAMESPACE, - KV_KEY, - seen_json.as_str(), - &UpdateSource::from_window_label(window.label()), - ); + window.with_tx(|tx| { + tx.set_key_value_raw( + KV_NAMESPACE, + KV_KEY, + seen_json.as_str(), + &UpdateSource::from_window_label(window.label()), + ); + Ok(()) + })?; Ok(()) } diff --git a/crates-tauri/yaak-app-client/src/plugin_events.rs b/crates-tauri/yaak-app-client/src/plugin_events.rs index 9071313c6..5a079a51c 100644 --- a/crates-tauri/yaak-app-client/src/plugin_events.rs +++ b/crates-tauri/yaak-app-client/src/plugin_events.rs @@ -1,5 +1,5 @@ use crate::error::Result; -use crate::http_request::send_http_request_with_context; +use crate::http_request::{SentHttpRequest, send_http_request_with_context}; use crate::models_ext::BlobManagerExt; use crate::models_ext::QueryManagerExt; use crate::render::{render_grpc_request, render_http_request, render_json_value}; @@ -12,7 +12,7 @@ use base64::prelude::BASE64_STANDARD; use chrono::Utc; use log::error; use std::sync::Arc; -use tauri::{AppHandle, Emitter, Listener, Manager, Runtime}; +use tauri::{AppHandle, Emitter, Listener, Manager, Runtime, WebviewWindow}; use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_plugin_opener::OpenerExt; use yaak::plugin_events::{ @@ -21,7 +21,7 @@ use yaak::plugin_events::{ 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}; +use yaak_models::models::{HttpRequest, HttpResponse, Plugin}; use yaak_models::queries::any_request::AnyRequest; use yaak_models::util::UpdateSource; use yaak_plugins::error::Error::PluginErr; @@ -111,7 +111,7 @@ async fn handle_host_plugin_request( } let new_plugin = Plugin { updated_at: Utc::now().naive_utc(), ..plugin }; - app_handle.db().upsert_plugin(&new_plugin, &UpdateSource::Plugin)?; + app_handle.with_tx(|tx| tx.upsert_plugin(&new_plugin, &UpdateSource::Plugin))?; } if !req.silent { @@ -280,48 +280,20 @@ async fn handle_host_plugin_request( } HostRequest::SendHttpRequest(req) => { let window = get_window_from_plugin_context(app_handle, plugin_context)?; - let mut http_request = req.http_request.clone(); - let workspace = - workspace_from_window(&window).expect("Failed to get workspace_id from window URL"); - let cookie_jar = cookie_jar_from_window(&window); - let environment = environment_from_window(&window); - - if http_request.workspace_id.is_empty() { - http_request.workspace_id = workspace.id; - } - - let http_response = if http_request.id.is_empty() { - HttpResponse::default() - } else { - let blobs = window.blob_manager(); - window.db().upsert_http_response( - &HttpResponse { - request_id: http_request.id.clone(), - workspace_id: http_request.workspace_id.clone(), - ..Default::default() - }, - &UpdateSource::from_window_label(window.label()), - &blobs, - )? - }; - - let http_response = send_http_request_with_context( + let sent = send_http_request_in_window( &window, - &http_request, - &http_response, - environment, - cookie_jar, - &mut tokio::sync::watch::channel(false).1, + req.http_request.clone(), + req.environment_id.as_deref(), plugin_context, ) .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)); + let body = sent.body.returned_bytes().map(|b| BASE64_STANDARD.encode(b)); Ok(Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse { - http_response: http_response.response, + http_response: sent.response, body, }))) } @@ -462,3 +434,63 @@ async fn handle_host_plugin_request( } } } + +/// Send on behalf of a plugin or MCP client, from `window`'s workspace and +/// with its selected environment unless `environment_id` overrides it. +pub(crate) async fn send_http_request_in_window( + window: &WebviewWindow, + mut http_request: HttpRequest, + environment_id: Option<&str>, + plugin_context: &yaak_plugins::events::PluginContext, +) -> Result { + let workspace = + workspace_from_window(window).expect("Failed to get workspace_id from window URL"); + let cookie_jar = cookie_jar_from_window(window); + + if http_request.workspace_id.is_empty() { + http_request.workspace_id = workspace.id.clone(); + } + + let environment = if let Some(environment_id) = environment_id { + if http_request.workspace_id != workspace.id { + return Err(crate::error::Error::GenericError( + "HTTP request does not belong to the selected workspace".to_string(), + )); + } + Some( + window + .db() + .get_environment_for_workspace(&http_request.workspace_id, environment_id)?, + ) + } else { + environment_from_window(window) + }; + + let http_response = if http_request.id.is_empty() { + HttpResponse::default() + } else { + let blobs = window.blob_manager(); + window.with_tx(|tx| { + tx.upsert_http_response( + &HttpResponse { + request_id: http_request.id.clone(), + workspace_id: http_request.workspace_id.clone(), + ..Default::default() + }, + &UpdateSource::from_window_label(window.label()), + &blobs, + ) + })? + }; + + send_http_request_with_context( + window, + &http_request, + &http_response, + environment, + cookie_jar, + &tokio::sync::watch::channel(false).1, + plugin_context, + ) + .await +} diff --git a/crates-tauri/yaak-app-client/src/plugins_ext.rs b/crates-tauri/yaak-app-client/src/plugins_ext.rs index bff3c6ed8..8e26c647b 100644 --- a/crates-tauri/yaak-app-client/src/plugins_ext.rs +++ b/crates-tauri/yaak-app-client/src/plugins_ext.rs @@ -16,8 +16,7 @@ use std::time::{Duration, Instant}; use tauri::path::BaseDirectory; use tauri::plugin::{Builder, TauriPlugin}; use tauri::{ - AppHandle, Emitter, Manager, RunEvent, Runtime, State, WebviewWindow, WindowEvent, - is_dev, + AppHandle, Emitter, Manager, RunEvent, Runtime, State, WebviewWindow, WindowEvent, is_dev, }; use tokio::sync::Mutex; use ts_rs::TS; @@ -28,10 +27,10 @@ use yaak_plugins::api::{ PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates, search_plugins, }; +use yaak_plugins::error::Error::PluginErr; 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); @@ -203,16 +202,18 @@ pub async fn cmd_plugins_install_from_directory( // 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(), - url: None, - enabled: true, - source: PluginSource::Filesystem, - ..Default::default() - }, - &UpdateSource::from_window_label(window.label()), - )?; + let plugin = window.with_tx(|tx| { + tx.upsert_plugin( + &Plugin { + directory: directory.into(), + url: None, + enabled: true, + source: PluginSource::Filesystem, + ..Default::default() + }, + &UpdateSource::from_window_label(window.label()), + ) + })?; plugin_manager.add_plugin(&window.plugin_context(), &plugin).await?; @@ -355,9 +356,11 @@ pub fn init() -> TauriPlugin { ), ) .await - .unwrap_or_else(|_| Err(yaak_plugins::error::Error::PluginErr( - "Timed out starting the plugin runtime".to_string(), - ))); + .unwrap_or_else(|_| { + Err(yaak_plugins::error::Error::PluginErr( + "Timed out starting the plugin runtime".to_string(), + )) + }); let manager = match result { Ok(manager) => manager, diff --git a/crates-tauri/yaak-app-client/src/render.rs b/crates-tauri/yaak-app-client/src/render.rs index 2c995b0d7..1cc1a8cfe 100644 --- a/crates-tauri/yaak-app-client/src/render.rs +++ b/crates-tauri/yaak-app-client/src/render.rs @@ -4,5 +4,5 @@ //! `yaak-commands` when the template commands did. Callers in this crate do not //! need to track which is which. -pub use yaak_models::render::{render_grpc_request, render_http_request}; pub use yaak_commands::render::{render_json_value, render_template}; +pub use yaak_models::render::{render_grpc_request, render_http_request}; diff --git a/crates-tauri/yaak-app-client/src/rpc_ext.rs b/crates-tauri/yaak-app-client/src/rpc_ext.rs index b9093af4f..ed8c5ac56 100644 --- a/crates-tauri/yaak-app-client/src/rpc_ext.rs +++ b/crates-tauri/yaak-app-client/src/rpc_ext.rs @@ -21,9 +21,9 @@ use crate::notifications::YaakNotifier; 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 tauri::{Manager, Runtime, State, WebviewWindow}; use tokio::sync::Mutex; use yaak_commands::{Host, PluginHost}; use yaak_core::WorkspaceContext; @@ -32,8 +32,8 @@ use yaak_git::{ BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote, GitStatusSummary, GitWorktreeStatus, PullResult, PushResult, }; -use yaak_grpc::manager::GrpcHandle; use yaak_grpc::ServiceDefinition; +use yaak_grpc::manager::GrpcHandle; use yaak_models::blob_manager::BlobManager; use yaak_models::models::{ GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse, @@ -42,26 +42,26 @@ use yaak_models::models::{ }; use yaak_models::query_manager::QueryManager; use yaak_models::util::{BatchUpsertResult, ImportPlan}; +use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse}; use yaak_plugins::events::{ CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest, - CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse, - JsonPrimitive, RenderPurpose, GetFolderActionsResponse, GetGrpcRequestActionsResponse, - GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, - GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse, - GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse, - GetWorkspaceActionsResponse, + CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, + GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse, + GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse, + GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse, + GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportResponse, JsonPrimitive, + RenderPurpose, }; -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_plugins::template_callback::PluginTemplateCallback; 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_templates::TemplateCallback; use yaak_ws::WebsocketManager; /// Per-call context: the window a command was invoked from. @@ -231,17 +231,15 @@ impl PluginHost for ClientCtx { Ok(self.pm().await?.call_workspace_action(&self.plugin_context(), req).await?) } - async fn call_folder_action( - &self, - req: CallFolderActionRequest, - ) -> yaak_commands::Result<()> { + 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> { - let results = self.pm().await?.get_http_authentication_summaries(&self.plugin_context()).await?; + let results = + self.pm().await?.get_http_authentication_summaries(&self.plugin_context()).await?; Ok(results.into_iter().map(|(_, a)| a).collect()) } @@ -393,11 +391,25 @@ async fn cmd_metadata(ctx: ClientCtx, _req: CmdMetadataReq) -> Re Ok(crate::cmd_metadata(ctx.window.app_handle().clone()).await?) } -async fn cmd_template_tokens_to_string(ctx: ClientCtx, req: CmdTemplateTokensToStringReq) -> Result { +async fn cmd_agent_setup_status( + ctx: ClientCtx, + req: CmdAgentSetupStatusReq, +) -> Result { + let home = ctx.window.app_handle().path().home_dir()?; + Ok(yaak_commands::agents::agent_setup_status(&home, req.agent)) +} + +async fn cmd_template_tokens_to_string( + ctx: ClientCtx, + req: CmdTemplateTokensToStringReq, +) -> Result { Ok(yaak_commands::templates::cmd_template_tokens_to_string(ctx, req).await?) } -async fn cmd_render_template(ctx: ClientCtx, req: CmdRenderTemplateReq) -> Result { +async fn cmd_render_template( + ctx: ClientCtx, + req: CmdRenderTemplateReq, +) -> Result { Ok(yaak_commands::templates::cmd_render_template(ctx, req).await?) } @@ -405,72 +417,144 @@ async fn cmd_send_feedback(ctx: ClientCtx, req: CmdSendFeedbackRe Ok(crate::cmd_send_feedback(ctx.window.app_handle().clone(), req.feature, req.text).await?) } -async fn cmd_dismiss_notification(ctx: ClientCtx, req: CmdDismissNotificationReq) -> Result<()> { - Ok(crate::cmd_dismiss_notification(ctx.window.clone(), &req.notification_id, ctx.window.app_handle().state::>()).await?) +async fn cmd_dismiss_notification( + ctx: ClientCtx, + req: CmdDismissNotificationReq, +) -> Result<()> { + Ok(crate::cmd_dismiss_notification( + ctx.window.clone(), + &req.notification_id, + ctx.window.app_handle().state::>(), + ) + .await?) } -async fn cmd_grpc_reflect(ctx: ClientCtx, req: CmdGrpcReflectReq) -> Result> { - Ok(crate::cmd_grpc_reflect(&req.request_id, req.environment_id.as_deref(), req.proto_files, ctx.window.clone(), ctx.window.app_handle().clone(), ctx.window.app_handle().state::>()).await?) +async fn cmd_grpc_reflect( + ctx: ClientCtx, + req: CmdGrpcReflectReq, +) -> Result> { + Ok(crate::cmd_grpc_reflect( + &req.request_id, + req.environment_id.as_deref(), + req.proto_files, + ctx.window.clone(), + ctx.window.app_handle().clone(), + ctx.window.app_handle().state::>(), + ) + .await?) } async fn cmd_grpc_go(ctx: ClientCtx, req: CmdGrpcGoReq) -> Result { - Ok(crate::cmd_grpc_go(&req.request_id, req.environment_id.as_deref(), req.proto_files, ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::>()).await?) + Ok(crate::cmd_grpc_go( + &req.request_id, + req.environment_id.as_deref(), + req.proto_files, + ctx.window.app_handle().clone(), + ctx.window.clone(), + ctx.window.app_handle().state::>(), + ) + .await?) } async fn cmd_restart(ctx: ClientCtx, _req: CmdRestartReq) -> Result<()> { Ok(crate::cmd_restart(ctx.window.app_handle().clone()).await?) } -async fn cmd_send_ephemeral_request(ctx: ClientCtx, req: CmdSendEphemeralRequestReq) -> Result { - 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_send_ephemeral_request( + ctx: ClientCtx, + req: CmdSendEphemeralRequestReq, +) -> Result { + 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(ctx: ClientCtx, req: CmdFormatJsonReq) -> Result { Ok(yaak_commands::data::cmd_format_json(ctx, req).await?) } -async fn cmd_format_graphql(_ctx: ClientCtx, req: CmdFormatGraphqlReq) -> Result { +async fn cmd_format_graphql( + _ctx: ClientCtx, + req: CmdFormatGraphqlReq, +) -> Result { Ok(crate::cmd_format_graphql(&req.text).await?) } -async fn cmd_http_response_body(ctx: ClientCtx, req: CmdHttpResponseBodyReq) -> Result { - Ok(crate::cmd_http_response_body(ctx.window.clone(), &req.response_id, req.filter.as_deref()).await?) +async fn cmd_http_response_body( + ctx: ClientCtx, + req: CmdHttpResponseBodyReq, +) -> Result { + Ok(crate::cmd_http_response_body(ctx.window.clone(), &req.response_id, req.filter.as_deref()) + .await?) } -async fn cmd_http_response_body_path(ctx: ClientCtx, req: CmdHttpResponseBodyPathReq) -> Result> { +async fn cmd_http_response_body_path( + ctx: ClientCtx, + req: CmdHttpResponseBodyPathReq, +) -> Result> { Ok(yaak_commands::responses::cmd_http_response_body_path(ctx, req).await?) } -async fn cmd_http_request_body(ctx: ClientCtx, req: CmdHttpRequestBodyReq) -> Result>> { +async fn cmd_http_request_body( + ctx: ClientCtx, + req: CmdHttpRequestBodyReq, +) -> Result>> { Ok(yaak_commands::responses::cmd_http_request_body(ctx, req).await?) } -async fn cmd_get_sse_events(ctx: ClientCtx, req: CmdGetSseEventsReq) -> Result> { +async fn cmd_get_sse_events( + ctx: ClientCtx, + req: CmdGetSseEventsReq, +) -> Result> { Ok(crate::cmd_get_sse_events(ctx.window.app_handle().clone(), &req.response_id).await?) } -async fn cmd_get_http_response_events(ctx: ClientCtx, req: CmdGetHttpResponseEventsReq) -> Result> { +async fn cmd_get_http_response_events( + ctx: ClientCtx, + req: CmdGetHttpResponseEventsReq, +) -> Result> { Ok(yaak_commands::responses::cmd_get_http_response_events(ctx, req).await?) } -async fn cmd_import_data(ctx: ClientCtx, req: CmdImportDataReq) -> Result { - Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path, req.destination).await?) +async fn cmd_import_data( + ctx: ClientCtx, + req: CmdImportDataReq, +) -> Result { + Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_paths, &req.urls, req.destination) + .await?) } -async fn cmd_import_url(ctx: ClientCtx, req: CmdImportUrlReq) -> Result { - Ok(crate::cmd_import_url(ctx.window.clone(), &req.url, req.destination).await?) -} - -async fn cmd_commit_import(ctx: ClientCtx, req: CmdCommitImportReq) -> Result { +async fn cmd_commit_import( + ctx: ClientCtx, + req: CmdCommitImportReq, +) -> Result { Ok(crate::cmd_commit_import(ctx.window.clone(), req.plan).await?) } -async fn cmd_list_import_sources(ctx: ClientCtx, req: CmdListImportSourcesReq) -> Result> { +async fn cmd_list_import_sources( + ctx: ClientCtx, + req: CmdListImportSourcesReq, +) -> Result> { use crate::models_ext::QueryManagerExt; Ok(ctx.window.db().list_import_sources(&req.workspace_id)?) } -async fn cmd_import_sources_for_origin(ctx: ClientCtx, req: CmdImportSourcesForOriginReq) -> Result> { +async fn cmd_detect_import_source( + ctx: ClientCtx, + req: CmdDetectImportSourceReq, +) -> Result { + Ok(crate::import::detect_import_source(&ctx.window, req.file_path, req.url).await?) +} + +async fn cmd_import_sources_for_origin( + ctx: ClientCtx, + req: CmdImportSourcesForOriginReq, +) -> Result> { use crate::models_ext::QueryManagerExt; let origin = match (req.file_path, req.url) { (Some(file_path), _) => crate::import::file_origin(&file_path).origin, @@ -483,151 +567,282 @@ async fn cmd_import_sources_for_origin(ctx: ClientCtx, req: CmdIm Ok(ctx.window.db().list_import_sources_by_origin(&origin)?) } -async fn cmd_http_request_actions(ctx: ClientCtx, req: CmdHttpRequestActionsReq) -> Result> { +async fn cmd_http_request_actions( + ctx: ClientCtx, + req: CmdHttpRequestActionsReq, +) -> Result> { Ok(yaak_commands::actions::cmd_http_request_actions(ctx, req).await?) } -async fn cmd_websocket_request_actions(ctx: ClientCtx, req: CmdWebsocketRequestActionsReq) -> Result> { +async fn cmd_websocket_request_actions( + ctx: ClientCtx, + req: CmdWebsocketRequestActionsReq, +) -> Result> { Ok(yaak_commands::actions::cmd_websocket_request_actions(ctx, req).await?) } -async fn cmd_call_websocket_request_action(ctx: ClientCtx, req: CmdCallWebsocketRequestActionReq) -> Result<()> { +async fn cmd_call_websocket_request_action( + ctx: ClientCtx, + req: CmdCallWebsocketRequestActionReq, +) -> Result<()> { Ok(yaak_commands::actions::cmd_call_websocket_request_action(ctx, req).await?) } -async fn cmd_workspace_actions(ctx: ClientCtx, req: CmdWorkspaceActionsReq) -> Result> { +async fn cmd_workspace_actions( + ctx: ClientCtx, + req: CmdWorkspaceActionsReq, +) -> Result> { Ok(yaak_commands::actions::cmd_workspace_actions(ctx, req).await?) } -async fn cmd_call_workspace_action(ctx: ClientCtx, req: CmdCallWorkspaceActionReq) -> Result<()> { +async fn cmd_call_workspace_action( + ctx: ClientCtx, + req: CmdCallWorkspaceActionReq, +) -> Result<()> { Ok(yaak_commands::actions::cmd_call_workspace_action(ctx, req).await?) } -async fn cmd_folder_actions(ctx: ClientCtx, req: CmdFolderActionsReq) -> Result> { +async fn cmd_folder_actions( + ctx: ClientCtx, + req: CmdFolderActionsReq, +) -> Result> { Ok(yaak_commands::actions::cmd_folder_actions(ctx, req).await?) } -async fn cmd_call_folder_action(ctx: ClientCtx, req: CmdCallFolderActionReq) -> Result<()> { +async fn cmd_call_folder_action( + ctx: ClientCtx, + req: CmdCallFolderActionReq, +) -> Result<()> { Ok(yaak_commands::actions::cmd_call_folder_action(ctx, req).await?) } -async fn cmd_grpc_request_actions(ctx: ClientCtx, req: CmdGrpcRequestActionsReq) -> Result> { +async fn cmd_grpc_request_actions( + ctx: ClientCtx, + req: CmdGrpcRequestActionsReq, +) -> Result> { Ok(yaak_commands::actions::cmd_grpc_request_actions(ctx, req).await?) } -async fn cmd_template_function_summaries(ctx: ClientCtx, req: CmdTemplateFunctionSummariesReq) -> Result> { +async fn cmd_template_function_summaries( + ctx: ClientCtx, + req: CmdTemplateFunctionSummariesReq, +) -> Result> { Ok(yaak_commands::templates::cmd_template_function_summaries(ctx, req).await?) } -async fn cmd_template_function_config(ctx: ClientCtx, req: CmdTemplateFunctionConfigReq) -> Result { +async fn cmd_template_function_config( + ctx: ClientCtx, + req: CmdTemplateFunctionConfigReq, +) -> Result { Ok(yaak_commands::templates::cmd_template_function_config(ctx, req).await?) } -async fn cmd_get_http_authentication_summaries(ctx: ClientCtx, req: CmdGetHttpAuthenticationSummariesReq) -> Result> { +async fn cmd_get_http_authentication_summaries( + ctx: ClientCtx, + req: CmdGetHttpAuthenticationSummariesReq, +) -> Result> { Ok(yaak_commands::auth::cmd_get_http_authentication_summaries(ctx, req).await?) } -async fn cmd_get_http_authentication_config(ctx: ClientCtx, req: CmdGetHttpAuthenticationConfigReq) -> Result { +async fn cmd_get_http_authentication_config( + ctx: ClientCtx, + req: CmdGetHttpAuthenticationConfigReq, +) -> Result { Ok(yaak_commands::auth::cmd_get_http_authentication_config(ctx, req).await?) } -async fn cmd_call_http_request_action(ctx: ClientCtx, req: CmdCallHttpRequestActionReq) -> Result<()> { +async fn cmd_call_http_request_action( + ctx: ClientCtx, + req: CmdCallHttpRequestActionReq, +) -> Result<()> { Ok(yaak_commands::actions::cmd_call_http_request_action(ctx, req).await?) } -async fn cmd_call_grpc_request_action(ctx: ClientCtx, req: CmdCallGrpcRequestActionReq) -> Result<()> { +async fn cmd_call_grpc_request_action( + ctx: ClientCtx, + req: CmdCallGrpcRequestActionReq, +) -> Result<()> { Ok(yaak_commands::actions::cmd_call_grpc_request_action(ctx, req).await?) } -async fn cmd_call_http_authentication_action(ctx: ClientCtx, req: CmdCallHttpAuthenticationActionReq) -> Result<()> { +async fn cmd_call_http_authentication_action( + ctx: ClientCtx, + req: CmdCallHttpAuthenticationActionReq, +) -> Result<()> { Ok(yaak_commands::auth::cmd_call_http_authentication_action(ctx, req).await?) } -async fn cmd_curl_to_request(ctx: ClientCtx, req: CmdCurlToRequestReq) -> Result { +async fn cmd_curl_to_request( + ctx: ClientCtx, + req: CmdCurlToRequestReq, +) -> Result { Ok(yaak_commands::actions::cmd_curl_to_request(ctx, req).await?) } -async fn cmd_export_data(ctx: ClientCtx, req: CmdExportDataReq) -> Result<()> { +async fn cmd_export_data(ctx: ClientCtx, req: CmdExportDataReq) -> Result { Ok(yaak_commands::data::cmd_export_data(ctx, req).await?) } -async fn cmd_save_base64_to_binary(ctx: ClientCtx, req: CmdSaveBase64ToBinaryReq) -> Result<()> { - Ok(crate::cmd_save_base64_to_binary(ctx.window.app_handle().clone(), &req.filepath, &req.data).await?) +async fn cmd_create_example_workspace( + ctx: ClientCtx, + req: CmdCreateExampleWorkspaceReq, +) -> Result { + Ok(yaak_commands::data::cmd_create_example_workspace(ctx, req).await?) +} + +async fn cmd_save_base64_to_binary( + ctx: ClientCtx, + req: CmdSaveBase64ToBinaryReq, +) -> Result<()> { + Ok(crate::cmd_save_base64_to_binary(ctx.window.app_handle().clone(), &req.filepath, &req.data) + .await?) } async fn cmd_save_response(ctx: ClientCtx, req: CmdSaveResponseReq) -> Result<()> { Ok(yaak_commands::responses::cmd_save_response(ctx, req).await?) } -async fn cmd_send_http_request(ctx: ClientCtx, req: CmdSendHttpRequestReq) -> Result { - 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_send_http_request( + ctx: ClientCtx, + req: CmdSendHttpRequestReq, +) -> Result { + 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(ctx: ClientCtx, req: CmdReloadPluginsReq) -> Result> { +async fn cmd_reload_plugins( + ctx: ClientCtx, + req: CmdReloadPluginsReq, +) -> Result> { Ok(yaak_commands::actions::cmd_reload_plugins(ctx, req).await?) } -async fn cmd_plugin_info(ctx: ClientCtx, req: CmdPluginInfoReq) -> Result { +async fn cmd_plugin_info( + ctx: ClientCtx, + req: CmdPluginInfoReq, +) -> Result { Ok(yaak_commands::plugins::cmd_plugin_info(ctx, req).await?) } -async fn cmd_delete_all_grpc_connections(ctx: ClientCtx, req: CmdDeleteAllGrpcConnectionsReq) -> Result<()> { +async fn cmd_delete_all_grpc_connections( + ctx: ClientCtx, + req: CmdDeleteAllGrpcConnectionsReq, +) -> Result<()> { Ok(yaak_commands::models::cmd_delete_all_grpc_connections(ctx, req).await?) } -async fn cmd_delete_send_history(ctx: ClientCtx, req: CmdDeleteSendHistoryReq) -> Result<()> { +async fn cmd_delete_send_history( + ctx: ClientCtx, + req: CmdDeleteSendHistoryReq, +) -> Result<()> { Ok(yaak_commands::models::cmd_delete_send_history(ctx, req).await?) } -async fn cmd_delete_all_http_responses(ctx: ClientCtx, req: CmdDeleteAllHttpResponsesReq) -> Result<()> { +async fn cmd_delete_all_http_responses( + ctx: ClientCtx, + req: CmdDeleteAllHttpResponsesReq, +) -> Result<()> { Ok(yaak_commands::models::cmd_delete_all_http_responses(ctx, req).await?) } -async fn cmd_get_workspace_meta(ctx: ClientCtx, req: CmdGetWorkspaceMetaReq) -> Result { +async fn cmd_get_workspace_meta( + ctx: ClientCtx, + req: CmdGetWorkspaceMetaReq, +) -> Result { Ok(yaak_commands::models::cmd_get_workspace_meta(ctx, req).await?) } -async fn cmd_new_child_window(ctx: ClientCtx, req: CmdNewChildWindowReq) -> Result<()> { - Ok(crate::cmd_new_child_window(ctx.window.clone(), &req.url, &req.label, &req.title, req.inner_size).await?) +async fn cmd_new_child_window( + ctx: ClientCtx, + req: CmdNewChildWindowReq, +) -> Result<()> { + Ok(crate::cmd_new_child_window( + ctx.window.clone(), + &req.url, + &req.label, + &req.title, + req.inner_size, + ) + .await?) } -async fn cmd_new_main_window(ctx: ClientCtx, req: CmdNewMainWindowReq) -> Result<()> { +async fn cmd_new_main_window( + ctx: ClientCtx, + req: CmdNewMainWindowReq, +) -> Result<()> { Ok(crate::cmd_new_main_window(ctx.window.app_handle().clone(), &req.url).await?) } -async fn cmd_check_for_updates(ctx: ClientCtx, _req: CmdCheckForUpdatesReq) -> Result { - Ok(crate::cmd_check_for_updates(ctx.window.clone(), ctx.window.app_handle().state::>()).await?) +async fn cmd_check_for_updates( + ctx: ClientCtx, + _req: CmdCheckForUpdatesReq, +) -> Result { + Ok(crate::cmd_check_for_updates( + ctx.window.clone(), + ctx.window.app_handle().state::>(), + ) + .await?) } -async fn cmd_decrypt_template(ctx: ClientCtx, req: CmdDecryptTemplateReq) -> Result { +async fn cmd_decrypt_template( + ctx: ClientCtx, + req: CmdDecryptTemplateReq, +) -> Result { Ok(yaak_commands::encryption::cmd_decrypt_template(ctx, req).await?) } -async fn cmd_secure_template(ctx: ClientCtx, req: CmdSecureTemplateReq) -> Result { +async fn cmd_secure_template( + ctx: ClientCtx, + req: CmdSecureTemplateReq, +) -> Result { Ok(yaak_commands::encryption::cmd_secure_template(ctx, req).await?) } -async fn cmd_get_themes(ctx: ClientCtx, req: CmdGetThemesReq) -> Result> { +async fn cmd_get_themes( + ctx: ClientCtx, + req: CmdGetThemesReq, +) -> Result> { Ok(yaak_commands::templates::cmd_get_themes(ctx, req).await?) } -async fn cmd_enable_encryption(ctx: ClientCtx, req: CmdEnableEncryptionReq) -> Result<()> { +async fn cmd_enable_encryption( + ctx: ClientCtx, + req: CmdEnableEncryptionReq, +) -> Result<()> { Ok(yaak_commands::encryption::cmd_enable_encryption(ctx, req).await?) } -async fn cmd_reveal_workspace_key(ctx: ClientCtx, req: CmdRevealWorkspaceKeyReq) -> Result { +async fn cmd_reveal_workspace_key( + ctx: ClientCtx, + req: CmdRevealWorkspaceKeyReq, +) -> Result { Ok(yaak_commands::encryption::cmd_reveal_workspace_key(ctx, req).await?) } -async fn cmd_set_workspace_key(ctx: ClientCtx, req: CmdSetWorkspaceKeyReq) -> Result<()> { +async fn cmd_set_workspace_key( + ctx: ClientCtx, + req: CmdSetWorkspaceKeyReq, +) -> Result<()> { Ok(yaak_commands::encryption::cmd_set_workspace_key(ctx, req).await?) } -async fn cmd_disable_encryption(ctx: ClientCtx, req: CmdDisableEncryptionReq) -> Result<()> { +async fn cmd_disable_encryption( + ctx: ClientCtx, + req: CmdDisableEncryptionReq, +) -> Result<()> { Ok(yaak_commands::encryption::cmd_disable_encryption(ctx, req).await?) } -async fn cmd_default_headers(ctx: ClientCtx, req: CmdDefaultHeadersReq) -> Result> { +async fn cmd_default_headers( + ctx: ClientCtx, + req: CmdDefaultHeadersReq, +) -> Result> { Ok(yaak_commands::models::cmd_default_headers(ctx, req).await?) } @@ -649,39 +864,66 @@ async fn models_delete(ctx: ClientCtx, req: ModelsDeleteReq) -> R Ok(deleted?) } -async fn models_duplicate(ctx: ClientCtx, req: ModelsDuplicateReq) -> Result { +async fn models_duplicate( + ctx: ClientCtx, + req: ModelsDuplicateReq, +) -> Result { Ok(yaak_commands::models::models_duplicate(ctx, req).await?) } -async fn models_snapshot_request(ctx: ClientCtx, req: ModelsSnapshotRequestReq) -> Result { +async fn models_snapshot_request( + ctx: ClientCtx, + req: ModelsSnapshotRequestReq, +) -> Result { Ok(yaak_commands::models::models_snapshot_request(ctx, req).await?) } -async fn models_request_version(ctx: ClientCtx, req: ModelsRequestVersionReq) -> Result { +async fn models_request_version( + ctx: ClientCtx, + req: ModelsRequestVersionReq, +) -> Result { Ok(yaak_commands::models::models_request_version(ctx, req).await?) } -async fn models_restore_request_version(ctx: ClientCtx, req: ModelsRestoreRequestVersionReq) -> Result { +async fn models_restore_request_version( + ctx: ClientCtx, + req: ModelsRestoreRequestVersionReq, +) -> Result { Ok(yaak_commands::models::models_restore_request_version(ctx, req).await?) } -async fn models_websocket_events(ctx: ClientCtx, req: ModelsWebsocketEventsReq) -> Result> { +async fn models_websocket_events( + ctx: ClientCtx, + req: ModelsWebsocketEventsReq, +) -> Result> { Ok(yaak_commands::models::models_websocket_events(ctx, req).await?) } -async fn models_grpc_events(ctx: ClientCtx, req: ModelsGrpcEventsReq) -> Result> { +async fn models_grpc_events( + ctx: ClientCtx, + req: ModelsGrpcEventsReq, +) -> Result> { Ok(yaak_commands::models::models_grpc_events(ctx, req).await?) } -async fn models_get_settings(ctx: ClientCtx, req: ModelsGetSettingsReq) -> Result { +async fn models_get_settings( + ctx: ClientCtx, + req: ModelsGetSettingsReq, +) -> Result { Ok(yaak_commands::models::models_get_settings(ctx, req).await?) } -async fn models_get_graphql_introspection(ctx: ClientCtx, req: ModelsGetGraphqlIntrospectionReq) -> Result> { +async fn models_get_graphql_introspection( + ctx: ClientCtx, + req: ModelsGetGraphqlIntrospectionReq, +) -> Result> { Ok(yaak_commands::models::models_get_graphql_introspection(ctx, req).await?) } -async fn models_upsert_graphql_introspection(ctx: ClientCtx, req: ModelsUpsertGraphqlIntrospectionReq) -> Result { +async fn models_upsert_graphql_introspection( + ctx: ClientCtx, + req: ModelsUpsertGraphqlIntrospectionReq, +) -> Result { Ok(yaak_commands::models::models_upsert_graphql_introspection(ctx, req).await?) } @@ -692,7 +934,10 @@ async fn models_upsert_graphql_introspection(ctx: ClientCtx, req: /// 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(ctx: ClientCtx, req: ModelsWorkspaceModelsReq) -> Result { +async fn models_workspace_models( + ctx: ClientCtx, + req: ModelsWorkspaceModelsReq, +) -> Result { let json = yaak_commands::models::models_workspace_models(ctx, req).await?; Ok(escape_str_for_webview(&json)) } @@ -718,7 +963,10 @@ fn escape_str_for_webview(input: &str) -> String { .collect() } -async fn cmd_git_checkout(_ctx: ClientCtx, req: CmdGitCheckoutReq) -> Result { +async fn cmd_git_checkout( + _ctx: ClientCtx, + req: CmdGitCheckoutReq, +) -> Result { Ok(crate::git_ext::cmd_git_checkout(&req.dir, &req.branch, req.force).await?) } @@ -726,31 +974,52 @@ async fn cmd_git_branch(_ctx: ClientCtx, req: CmdGitBranchReq) -> Ok(crate::git_ext::cmd_git_branch(&req.dir, &req.branch, req.base.as_deref()).await?) } -async fn cmd_git_delete_branch(_ctx: ClientCtx, req: CmdGitDeleteBranchReq) -> Result { +async fn cmd_git_delete_branch( + _ctx: ClientCtx, + req: CmdGitDeleteBranchReq, +) -> Result { Ok(crate::git_ext::cmd_git_delete_branch(&req.dir, &req.branch, req.force).await?) } -async fn cmd_git_delete_remote_branch(_ctx: ClientCtx, req: CmdGitDeleteRemoteBranchReq) -> Result<()> { +async fn cmd_git_delete_remote_branch( + _ctx: ClientCtx, + req: CmdGitDeleteRemoteBranchReq, +) -> Result<()> { Ok(crate::git_ext::cmd_git_delete_remote_branch(&req.dir, &req.branch).await?) } -async fn cmd_git_merge_branch(_ctx: ClientCtx, req: CmdGitMergeBranchReq) -> Result<()> { +async fn cmd_git_merge_branch( + _ctx: ClientCtx, + req: CmdGitMergeBranchReq, +) -> Result<()> { Ok(crate::git_ext::cmd_git_merge_branch(&req.dir, &req.branch).await?) } -async fn cmd_git_rename_branch(_ctx: ClientCtx, req: CmdGitRenameBranchReq) -> Result<()> { +async fn cmd_git_rename_branch( + _ctx: ClientCtx, + req: CmdGitRenameBranchReq, +) -> Result<()> { Ok(crate::git_ext::cmd_git_rename_branch(&req.dir, &req.old_name, &req.new_name).await?) } -async fn cmd_git_status(_ctx: ClientCtx, req: CmdGitStatusReq) -> Result { +async fn cmd_git_status( + _ctx: ClientCtx, + req: CmdGitStatusReq, +) -> Result { Ok(crate::git_ext::cmd_git_status(&req.dir).await?) } -async fn cmd_git_branch_info(_ctx: ClientCtx, req: CmdGitBranchInfoReq) -> Result { +async fn cmd_git_branch_info( + _ctx: ClientCtx, + req: CmdGitBranchInfoReq, +) -> Result { Ok(crate::git_ext::cmd_git_branch_info(&req.dir).await?) } -async fn cmd_git_worktree_status(_ctx: ClientCtx, req: CmdGitWorktreeStatusReq) -> Result { +async fn cmd_git_worktree_status( + _ctx: ClientCtx, + req: CmdGitWorktreeStatusReq, +) -> Result { Ok(crate::git_ext::cmd_git_worktree_status(&req.dir).await?) } @@ -758,15 +1027,25 @@ async fn cmd_git_log(_ctx: ClientCtx, req: CmdGitLogReq) -> Resul Ok(crate::git_ext::cmd_git_log(&req.dir).await?) } -async fn cmd_git_log_for_file(_ctx: ClientCtx, req: CmdGitLogForFileReq) -> Result> { +async fn cmd_git_log_for_file( + _ctx: ClientCtx, + req: CmdGitLogForFileReq, +) -> Result> { Ok(crate::git_ext::cmd_git_log_for_file(&req.dir, req.rela_path).await?) } -async fn cmd_git_file_diff_for_commit(_ctx: ClientCtx, req: CmdGitFileDiffForCommitReq) -> Result { - Ok(crate::git_ext::cmd_git_file_diff_for_commit(&req.dir, &req.commit_oid, req.rela_path).await?) +async fn cmd_git_file_diff_for_commit( + _ctx: ClientCtx, + req: CmdGitFileDiffForCommitReq, +) -> Result { + Ok(crate::git_ext::cmd_git_file_diff_for_commit(&req.dir, &req.commit_oid, req.rela_path) + .await?) } -async fn cmd_git_initialize(_ctx: ClientCtx, req: CmdGitInitializeReq) -> Result<()> { +async fn cmd_git_initialize( + _ctx: ClientCtx, + req: CmdGitInitializeReq, +) -> Result<()> { Ok(crate::git_ext::cmd_git_initialize(&req.dir).await?) } @@ -790,11 +1069,17 @@ async fn cmd_git_pull(_ctx: ClientCtx, req: CmdGitPullReq) -> Res Ok(crate::git_ext::cmd_git_pull(&req.dir).await?) } -async fn cmd_git_pull_force_reset(_ctx: ClientCtx, req: CmdGitPullForceResetReq) -> Result { +async fn cmd_git_pull_force_reset( + _ctx: ClientCtx, + req: CmdGitPullForceResetReq, +) -> Result { Ok(crate::git_ext::cmd_git_pull_force_reset(&req.dir, &req.remote, &req.branch).await?) } -async fn cmd_git_pull_merge(_ctx: ClientCtx, req: CmdGitPullMergeReq) -> Result { +async fn cmd_git_pull_merge( + _ctx: ClientCtx, + req: CmdGitPullMergeReq, +) -> Result { Ok(crate::git_ext::cmd_git_pull_merge(&req.dir, &req.remote, &req.branch).await?) } @@ -806,27 +1091,47 @@ async fn cmd_git_unstage(_ctx: ClientCtx, req: CmdGitUnstageReq) Ok(crate::git_ext::cmd_git_unstage(&req.dir, req.rela_paths).await?) } -async fn cmd_git_reset_changes(_ctx: ClientCtx, req: CmdGitResetChangesReq) -> Result<()> { +async fn cmd_git_reset_changes( + _ctx: ClientCtx, + req: CmdGitResetChangesReq, +) -> Result<()> { Ok(crate::git_ext::cmd_git_reset_changes(&req.dir).await?) } -async fn cmd_git_restore_files(_ctx: ClientCtx, req: CmdGitRestoreFilesReq) -> Result<()> { +async fn cmd_git_restore_files( + _ctx: ClientCtx, + req: CmdGitRestoreFilesReq, +) -> Result<()> { Ok(crate::git_ext::cmd_git_restore_files(&req.dir, req.rela_paths).await?) } -async fn cmd_git_restore_file_from_commit(_ctx: ClientCtx, req: CmdGitRestoreFileFromCommitReq) -> Result<()> { - Ok(crate::git_ext::cmd_git_restore_file_from_commit(&req.dir, &req.commit_oid, req.rela_path).await?) +async fn cmd_git_restore_file_from_commit( + _ctx: ClientCtx, + req: CmdGitRestoreFileFromCommitReq, +) -> Result<()> { + Ok(crate::git_ext::cmd_git_restore_file_from_commit(&req.dir, &req.commit_oid, req.rela_path) + .await?) } -async fn cmd_git_add_credential(_ctx: ClientCtx, req: CmdGitAddCredentialReq) -> Result<()> { - Ok(crate::git_ext::cmd_git_add_credential(&req.remote_url, &req.username, &req.password).await?) +async fn cmd_git_add_credential( + _ctx: ClientCtx, + req: CmdGitAddCredentialReq, +) -> Result<()> { + Ok(crate::git_ext::cmd_git_add_credential(&req.remote_url, &req.username, &req.password) + .await?) } -async fn cmd_git_remotes(_ctx: ClientCtx, req: CmdGitRemotesReq) -> Result> { +async fn cmd_git_remotes( + _ctx: ClientCtx, + req: CmdGitRemotesReq, +) -> Result> { Ok(crate::git_ext::cmd_git_remotes(&req.dir).await?) } -async fn cmd_git_add_remote(_ctx: ClientCtx, req: CmdGitAddRemoteReq) -> Result { +async fn cmd_git_add_remote( + _ctx: ClientCtx, + req: CmdGitAddRemoteReq, +) -> Result { Ok(crate::git_ext::cmd_git_add_remote(&req.dir, &req.name, &req.url).await?) } @@ -834,58 +1139,131 @@ async fn cmd_git_rm_remote(_ctx: ClientCtx, req: CmdGitRmRemoteRe Ok(crate::git_ext::cmd_git_rm_remote(&req.dir, &req.name).await?) } -async fn cmd_sync_calculate(ctx: ClientCtx, req: CmdSyncCalculateReq) -> Result> { - Ok(crate::sync_ext::cmd_sync_calculate(ctx.window.app_handle().clone(), &req.workspace_id, &req.sync_dir).await?) +async fn cmd_sync_calculate( + ctx: ClientCtx, + req: CmdSyncCalculateReq, +) -> Result> { + Ok(crate::sync_ext::cmd_sync_calculate( + ctx.window.app_handle().clone(), + &req.workspace_id, + &req.sync_dir, + ) + .await?) } -async fn cmd_sync_calculate_fs(_ctx: ClientCtx, req: CmdSyncCalculateFsReq) -> Result> { +async fn cmd_sync_calculate_fs( + _ctx: ClientCtx, + req: CmdSyncCalculateFsReq, +) -> Result> { Ok(crate::sync_ext::cmd_sync_calculate_fs(&req.dir).await?) } -async fn cmd_sync_apply(ctx: ClientCtx, req: CmdSyncApplyReq) -> Result<()> { - Ok(crate::sync_ext::cmd_sync_apply(ctx.window.app_handle().clone(), req.sync_ops, &req.sync_dir, &req.workspace_id).await?) +async fn cmd_sync_apply(ctx: ClientCtx, req: CmdSyncApplyReq) -> Result { + Ok(crate::sync_ext::cmd_sync_apply( + ctx.window.app_handle().clone(), + req.sync_ops, + &req.sync_dir, + &req.workspace_id, + req.fs_only, + ) + .await?) } -async fn cmd_ws_delete_connections(ctx: ClientCtx, req: CmdWsDeleteConnectionsReq) -> Result<()> { +async fn cmd_ws_delete_connections( + ctx: ClientCtx, + req: CmdWsDeleteConnectionsReq, +) -> Result<()> { Ok(yaak_commands::models::cmd_ws_delete_connections(ctx, req).await?) } -async fn cmd_ws_send(ctx: ClientCtx, req: CmdWsSendReq) -> Result { - Ok(crate::ws_ext::cmd_ws_send(&req.connection_id, req.environment_id.as_deref(), ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::>()).await?) +async fn cmd_ws_send( + ctx: ClientCtx, + req: CmdWsSendReq, +) -> Result { + Ok(crate::ws_ext::cmd_ws_send( + &req.connection_id, + req.environment_id.as_deref(), + ctx.window.app_handle().clone(), + ctx.window.clone(), + ctx.window.app_handle().state::>(), + ) + .await?) } -async fn cmd_ws_close(ctx: ClientCtx, req: CmdWsCloseReq) -> Result { - Ok(crate::ws_ext::cmd_ws_close(&req.connection_id, ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::>()).await?) +async fn cmd_ws_close( + ctx: ClientCtx, + req: CmdWsCloseReq, +) -> Result { + Ok(crate::ws_ext::cmd_ws_close( + &req.connection_id, + ctx.window.app_handle().clone(), + ctx.window.clone(), + ctx.window.app_handle().state::>(), + ) + .await?) } -async fn cmd_ws_connect(ctx: ClientCtx, req: CmdWsConnectReq) -> Result { - 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::>()).await?) +async fn cmd_ws_connect( + ctx: ClientCtx, + req: CmdWsConnectReq, +) -> Result { + 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::>(), + ) + .await?) } -async fn cmd_plugins_search(ctx: ClientCtx, req: CmdPluginsSearchReq) -> Result { +async fn cmd_plugins_search( + ctx: ClientCtx, + req: CmdPluginsSearchReq, +) -> Result { Ok(crate::plugins_ext::cmd_plugins_search(ctx.window.app_handle().clone(), &req.query).await?) } -async fn cmd_plugins_install(ctx: ClientCtx, req: CmdPluginsInstallReq) -> Result<()> { +async fn cmd_plugins_install( + ctx: ClientCtx, + req: CmdPluginsInstallReq, +) -> Result<()> { Ok(crate::plugins_ext::cmd_plugins_install(ctx.window.clone(), &req.name, req.version).await?) } -async fn cmd_plugins_install_from_directory(ctx: ClientCtx, req: CmdPluginsInstallFromDirectoryReq) -> Result { - Ok(crate::plugins_ext::cmd_plugins_install_from_directory(ctx.window.clone(), &req.directory).await?) +async fn cmd_plugins_install_from_directory( + ctx: ClientCtx, + req: CmdPluginsInstallFromDirectoryReq, +) -> Result { + Ok(crate::plugins_ext::cmd_plugins_install_from_directory(ctx.window.clone(), &req.directory) + .await?) } -async fn cmd_plugins_uninstall(ctx: ClientCtx, req: CmdPluginsUninstallReq) -> Result { +async fn cmd_plugins_uninstall( + ctx: ClientCtx, + req: CmdPluginsUninstallReq, +) -> Result { Ok(crate::plugins_ext::cmd_plugins_uninstall(&req.plugin_id, ctx.window.clone()).await?) } -async fn cmd_plugin_init_errors(ctx: ClientCtx, req: CmdPluginInitErrorsReq) -> Result> { +async fn cmd_plugin_init_errors( + ctx: ClientCtx, + req: CmdPluginInitErrorsReq, +) -> Result> { Ok(yaak_commands::plugins::cmd_plugin_init_errors(ctx, req).await?) } -async fn cmd_plugins_updates(ctx: ClientCtx, _req: CmdPluginsUpdatesReq) -> Result { +async fn cmd_plugins_updates( + ctx: ClientCtx, + _req: CmdPluginsUpdatesReq, +) -> Result { Ok(crate::plugins_ext::cmd_plugins_updates(ctx.window.app_handle().clone()).await?) } -async fn cmd_plugins_update_all(ctx: ClientCtx, _req: CmdPluginsUpdateAllReq) -> Result> { +async fn cmd_plugins_update_all( + ctx: ClientCtx, + _req: CmdPluginsUpdateAllReq, +) -> Result> { Ok(crate::plugins_ext::cmd_plugins_update_all(ctx.window.clone()).await?) } diff --git a/crates-tauri/yaak-app-client/src/sync_ext.rs b/crates-tauri/yaak-app-client/src/sync_ext.rs index 223573ce0..e8f2a0a64 100644 --- a/crates-tauri/yaak-app-client/src/sync_ext.rs +++ b/crates-tauri/yaak-app-client/src/sync_ext.rs @@ -12,8 +12,8 @@ use tokio::sync::watch; use yaak_rpc_schema::WatchResult; use yaak_sync::error::Error::InvalidSyncDirectory; use yaak_sync::sync::{ - FsCandidate, SyncOp, apply_sync_ops, apply_sync_state_ops, compute_sync_ops, get_db_candidates, - get_fs_candidates, + FsCandidate, SyncOp, apply_db_sync_ops, apply_fs_sync_ops, apply_sync_ops, + apply_sync_state_ops, compute_sync_ops, get_db_candidates, get_fs_candidates, }; use yaak_sync::watch::{WatchEvent, watch_directory}; @@ -29,7 +29,7 @@ pub(crate) async fn cmd_sync_calculate( let db = app_handle.db(); let version = app_handle.package_info().version.to_string(); let db_candidates = get_db_candidates(&db, &version, workspace_id, sync_dir)?; - let fs_candidates = get_fs_candidates(sync_dir)? + let fs_candidates = get_fs_candidates(sync_dir, &db_candidates)? .into_iter() // Only keep items in the same workspace .filter(|fs| fs.model.workspace_id() == workspace_id) @@ -39,7 +39,7 @@ pub(crate) async fn cmd_sync_calculate( pub(crate) async fn cmd_sync_calculate_fs(dir: &Path) -> Result> { let db_candidates = Vec::new(); - let fs_candidates = get_fs_candidates(dir)?; + let fs_candidates = get_fs_candidates(dir, &db_candidates)?; Ok(compute_sync_ops(db_candidates, fs_candidates)) } @@ -48,12 +48,29 @@ pub(crate) async fn cmd_sync_apply( sync_ops: Vec, sync_dir: &Path, workspace_id: &str, -) -> Result<()> { - let db = app_handle.db(); + fs_only: bool, +) -> Result { let blobs = app_handle.blob_manager(); - let sync_state_ops = apply_sync_ops(&db, &blobs, workspace_id, sync_dir, sync_ops)?; - apply_sync_state_ops(&db, workspace_id, sync_dir, sync_state_ops)?; - Ok(()) + if !fs_only || sync_ops.iter().any(|op| !matches!(op, SyncOp::DbCreate { .. })) { + let version = app_handle.package_info().version.to_string(); + return Ok(apply_sync_ops( + &app_handle.db_manager(), + &blobs, + &version, + workspace_id, + sync_dir, + sync_ops, + )?); + } + + // Opening a sync directory imports its files without existing DB sync state. + // Files first, so the write transaction never waits on the filesystem + let pending = apply_fs_sync_ops(workspace_id, sync_dir, sync_ops)?; + app_handle.db_manager().with_tx(|tx| { + let sync_state_ops = apply_db_sync_ops(tx, &blobs, workspace_id, sync_dir, pending)?; + apply_sync_state_ops(tx, workspace_id, sync_dir, sync_state_ops)?; + Ok(true) + }) } pub(crate) async fn sync_watch( diff --git a/crates-tauri/yaak-app-client/src/uri_scheme.rs b/crates-tauri/yaak-app-client/src/uri_scheme.rs index e6bd13dab..ee6d51ea3 100644 --- a/crates-tauri/yaak-app-client/src/uri_scheme.rs +++ b/crates-tauri/yaak-app-client/src/uri_scheme.rs @@ -3,16 +3,26 @@ use crate::error::Result; use crate::import::{file_origin, import_data, url_origin}; use crate::models_ext::QueryManagerExt; use log::{info, warn}; +use serde::Serialize; use std::collections::HashMap; use std::fs; use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager, Runtime, Url}; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; +use ts_rs::TS; 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; +#[derive(Debug, Clone, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "index.ts")] +pub struct NavigateWorkspace { + workspace_id: String, + environment_id: Option, +} + pub(crate) async fn handle_deep_link( app_handle: &AppHandle, url: &Url, @@ -129,6 +139,15 @@ pub(crate) async fn handle_deep_link( timeout: Some(5000), }, )?; + if let Some(workspace) = results.workspaces.first() { + window.emit( + "navigate_workspace", + NavigateWorkspace { + workspace_id: workspace.id.clone(), + environment_id: results.environments.first().map(|e| e.id.clone()), + }, + )?; + } } _ => { warn!("Unknown deep link command: {command}"); diff --git a/crates-tauri/yaak-app-client/src/window_menu.rs b/crates-tauri/yaak-app-client/src/window_menu.rs index 414912472..df2974d68 100644 --- a/crates-tauri/yaak-app-client/src/window_menu.rs +++ b/crates-tauri/yaak-app-client/src/window_menu.rs @@ -165,6 +165,8 @@ pub fn app_menu(app_handle: &AppHandle) -> tauri::Result> "Generate Theme CSS", ) .build(app_handle)?, + &MenuItemBuilder::with_id("dev.show_home".to_string(), "Show Home Screen") + .build(app_handle)?, ], )?, ], diff --git a/crates-tauri/yaak-app-client/src/ws_ext.rs b/crates-tauri/yaak-app-client/src/ws_ext.rs index dae701cf1..d720d6898 100644 --- a/crates-tauri/yaak-app-client/src/ws_ext.rs +++ b/crates-tauri/yaak-app-client/src/ws_ext.rs @@ -13,6 +13,7 @@ use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow}; use tokio::sync::{Mutex, mpsc}; use tokio_tungstenite::tungstenite::Message; use url::Url; +use yaak_commands::resolve::resolve_websocket_request; use yaak_crypto::manager::EncryptionManager; use yaak_http::cookies::CookieStore; use yaak_http::path_placeholders::apply_path_placeholders; @@ -27,7 +28,6 @@ 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_send( @@ -44,18 +44,20 @@ pub async fn cmd_ws_send( { Ok(connection) => Ok(connection), Err(e) => { - app_handle.db().upsert_websocket_event( - &WebsocketEvent { - connection_id: connection.id.clone(), - request_id: connection.request_id.clone(), - workspace_id: connection.workspace_id.clone(), - is_server: false, - message_type: WebsocketEventType::Error, - message: e.to_string().into(), - ..Default::default() - }, - &UpdateSource::from_window_label(window.label()), - )?; + app_handle.with_tx(|tx| { + tx.upsert_websocket_event( + &WebsocketEvent { + connection_id: connection.id.clone(), + request_id: connection.request_id.clone(), + workspace_id: connection.workspace_id.clone(), + is_server: false, + message_type: WebsocketEventType::Error, + message: e.to_string().into(), + ..Default::default() + }, + &UpdateSource::from_window_label(window.label()), + ) + })?; Ok(connection) } @@ -97,18 +99,20 @@ async fn send_websocket_message( let mut ws_manager = ws_manager.lock().await; ws_manager.send(&connection.id, Message::Text(message.clone().into())).await?; - app_handle.db().upsert_websocket_event( - &WebsocketEvent { - connection_id: connection.id.clone(), - request_id: request.id.clone(), - workspace_id: connection.workspace_id.clone(), - is_server: false, - message_type: WebsocketEventType::Text, - message: message.into(), - ..Default::default() - }, - &UpdateSource::from_window_label(window.label()), - )?; + app_handle.with_tx(|tx| { + tx.upsert_websocket_event( + &WebsocketEvent { + connection_id: connection.id.clone(), + request_id: request.id.clone(), + workspace_id: connection.workspace_id.clone(), + is_server: false, + message_type: WebsocketEventType::Text, + message: message.into(), + ..Default::default() + }, + &UpdateSource::from_window_label(window.label()), + ) + })?; Ok(connection.clone()) } @@ -119,14 +123,13 @@ pub async fn cmd_ws_close( window: WebviewWindow, ws_manager: State<'_, Mutex>, ) -> Result { - let connection = { - let db = app_handle.db(); - let connection = db.get_websocket_connection(connection_id)?; - db.upsert_websocket_connection( + let connection = app_handle.with_tx(|tx| { + let connection = tx.get_websocket_connection(connection_id)?; + tx.upsert_websocket_connection( &WebsocketConnection { state: WebsocketConnectionState::Closing, ..connection }, &UpdateSource::from_window_label(window.label()), - )? - }; + ) + })?; let mut ws_manager = ws_manager.lock().await; if let Err(e) = ws_manager.close(&connection.id).await { @@ -170,21 +173,22 @@ pub async fn cmd_ws_connect( ) .await?; - // Capture the stored request, not the rendered one: what a restore should - // put back is what the user typed - let version_id = app_handle - .db() - .snapshot_request_for_send(&AnyRequest::WebsocketRequest(unrendered_request.clone())); - - let connection = app_handle.db().upsert_websocket_connection( - &WebsocketConnection { - workspace_id: request.workspace_id.clone(), - request_id: request_id.to_string(), - version_id, - ..Default::default() - }, - &UpdateSource::from_window_label(window.label()), - )?; + let connection = app_handle.with_tx(|tx| { + // Capture the stored request, not the rendered one: what a restore should + // put back is what the user typed + let version_id = tx.snapshot_request_for_send(&AnyRequest::WebsocketRequest( + unrendered_request.clone(), + )); + tx.upsert_websocket_connection( + &WebsocketConnection { + workspace_id: request.workspace_id.clone(), + request_id: request_id.to_string(), + version_id, + ..Default::default() + }, + &UpdateSource::from_window_label(window.label()), + ) + })?; let (mut url, url_parameters) = apply_path_placeholders(&request.url, &request.url_parameters); if !url.starts_with("ws://") && !url.starts_with("wss://") { @@ -195,14 +199,16 @@ pub async fn cmd_ws_connect( let mut url = match Url::parse(&url) { Ok(url) => url, Err(e) => { - return Ok(app_handle.db().upsert_websocket_connection( - &WebsocketConnection { - error: Some(format!("Failed to parse URL {}", e.to_string())), - state: WebsocketConnectionState::Closed, - ..connection - }, - &UpdateSource::from_window_label(window.label()), - )?); + return Ok(app_handle.with_tx(|tx| { + tx.upsert_websocket_connection( + &WebsocketConnection { + error: Some(format!("Failed to parse URL {}", e.to_string())), + state: WebsocketConnectionState::Closed, + ..connection + }, + &UpdateSource::from_window_label(window.label()), + ) + })?); } }; @@ -329,28 +335,32 @@ pub async fn cmd_ws_connect( { Ok(r) => r, Err(e) => { - return Ok(app_handle.db().upsert_websocket_connection( - &WebsocketConnection { - error: Some(e.to_string()), - state: WebsocketConnectionState::Closed, - ..connection - }, - &UpdateSource::from_window_label(window.label()), - )?); + return Ok(app_handle.with_tx(|tx| { + tx.upsert_websocket_connection( + &WebsocketConnection { + error: Some(e.to_string()), + state: WebsocketConnectionState::Closed, + ..connection + }, + &UpdateSource::from_window_label(window.label()), + ) + })?); } }; - app_handle.db().upsert_websocket_event( - &WebsocketEvent { - connection_id: connection.id.clone(), - request_id: request.id.clone(), - workspace_id: connection.workspace_id.clone(), - is_server: false, - message_type: WebsocketEventType::Open, - ..Default::default() - }, - &UpdateSource::from_window_label(window.label()), - )?; + app_handle.with_tx(|tx| { + tx.upsert_websocket_event( + &WebsocketEvent { + connection_id: connection.id.clone(), + request_id: request.id.clone(), + workspace_id: connection.workspace_id.clone(), + is_server: false, + message_type: WebsocketEventType::Open, + ..Default::default() + }, + &UpdateSource::from_window_label(window.label()), + ) + })?; let response_headers = response .headers() @@ -374,20 +384,22 @@ pub async fn cmd_ws_connect( if !set_cookie_headers.is_empty() { store.store_cookies_from_response(&convert_ws_url_to_http(&url), &set_cookie_headers); cookie_jar.cookies = store.get_all_cookies(); - app_handle.db().upsert_cookie_jar(cookie_jar, &UpdateSource::Background)?; + app_handle.with_tx(|tx| tx.upsert_cookie_jar(cookie_jar, &UpdateSource::Background))?; } } - let connection = app_handle.db().upsert_websocket_connection( - &WebsocketConnection { - state: WebsocketConnectionState::Connected, - headers: response_headers, - status: response.status().as_u16() as i32, - url: request.url.clone(), - ..connection - }, - &UpdateSource::from_window_label(window.label()), - )?; + let connection = app_handle.with_tx(|tx| { + tx.upsert_websocket_connection( + &WebsocketConnection { + state: WebsocketConnectionState::Connected, + headers: response_headers, + status: response.status().as_u16() as i32, + url: request.url.clone(), + ..connection + }, + &UpdateSource::from_window_label(window.label()), + ) + })?; { let connection_id = connection.id.clone(); @@ -403,57 +415,60 @@ pub async fn cmd_ws_connect( } app_handle - .db() - .upsert_websocket_event( - &WebsocketEvent { - connection_id: connection_id.clone(), - request_id: request_id.clone(), - workspace_id: workspace_id.clone(), - is_server: true, - message_type: match message { - Message::Text(_) => WebsocketEventType::Text, - Message::Binary(_) => WebsocketEventType::Binary, - Message::Ping(_) => WebsocketEventType::Ping, - Message::Pong(_) => WebsocketEventType::Pong, - Message::Close(_) => WebsocketEventType::Close, - // Raw frame will never happen during a read - Message::Frame(_) => WebsocketEventType::Frame, + .with_tx(|tx| { + tx.upsert_websocket_event( + &WebsocketEvent { + connection_id: connection_id.clone(), + request_id: request_id.clone(), + workspace_id: workspace_id.clone(), + is_server: true, + message_type: match message { + Message::Text(_) => WebsocketEventType::Text, + Message::Binary(_) => WebsocketEventType::Binary, + Message::Ping(_) => WebsocketEventType::Ping, + Message::Pong(_) => WebsocketEventType::Pong, + Message::Close(_) => WebsocketEventType::Close, + // Raw frame will never happen during a read + Message::Frame(_) => WebsocketEventType::Frame, + }, + message: message.into_data().into(), + ..Default::default() }, - message: message.into_data().into(), - ..Default::default() - }, - &UpdateSource::from_window_label(&window_label), - ) + &UpdateSource::from_window_label(&window_label), + ) + }) .unwrap(); } info!("Websocket connection closed"); if !has_written_close { app_handle - .db() - .upsert_websocket_event( - &WebsocketEvent { - connection_id: connection_id.clone(), - request_id: request_id.clone(), - workspace_id: workspace_id.clone(), - is_server: true, - message_type: WebsocketEventType::Close, - ..Default::default() - }, - &UpdateSource::from_window_label(&window_label), - ) + .with_tx(|tx| { + tx.upsert_websocket_event( + &WebsocketEvent { + connection_id: connection_id.clone(), + request_id: request_id.clone(), + workspace_id: workspace_id.clone(), + is_server: true, + message_type: WebsocketEventType::Close, + ..Default::default() + }, + &UpdateSource::from_window_label(&window_label), + ) + }) .unwrap(); } app_handle - .db() - .upsert_websocket_connection( - &WebsocketConnection { - workspace_id: request.workspace_id.clone(), - request_id: request_id.to_string(), - state: WebsocketConnectionState::Closed, - ..connection - }, - &UpdateSource::from_window_label(&window_label), - ) + .with_tx(|tx| { + tx.upsert_websocket_connection( + &WebsocketConnection { + workspace_id: request.workspace_id.clone(), + request_id: request_id.to_string(), + state: WebsocketConnectionState::Closed, + ..connection + }, + &UpdateSource::from_window_label(&window_label), + ) + }) .unwrap(); }); } @@ -461,7 +476,6 @@ pub async fn cmd_ws_connect( Ok(connection) } - /// Convert WS URL to HTTP URL for cookie filtering /// WebSocket upgrade requests are HTTP requests initially, so HttpOnly cookies should apply fn convert_ws_url_to_http(ws_url: &Url) -> Url { diff --git a/crates-tauri/yaak-app-proxy/.gitignore b/crates-tauri/yaak-app-proxy/.gitignore index 8cc967540..69bebf61b 100644 --- a/crates-tauri/yaak-app-proxy/.gitignore +++ b/crates-tauri/yaak-app-proxy/.gitignore @@ -2,6 +2,8 @@ # will have compiled files and executables target/ +vendored/* + gen/* **/permissions/autogenerated diff --git a/crates-tauri/yaak-app-proxy/Cargo.toml b/crates-tauri/yaak-app-proxy/Cargo.toml index a9bdbd567..b9101b213 100644 --- a/crates-tauri/yaak-app-proxy/Cargo.toml +++ b/crates-tauri/yaak-app-proxy/Cargo.toml @@ -14,12 +14,14 @@ tauri-build = { version = "2.6.1", features = [] } [features] default = ["wry"] -wry = ["tauri/wry", "tauri/x11", "tauri/dbus"] +wry = ["tauri/wry", "tauri/x11", "tauri/dbus", "tauri-plugin-dialog/gtk3"] [dependencies] log = { workspace = true } serde_json = { workspace = true } tauri = { workspace = true, features = ["devtools"] } +tauri-plugin-dialog = { workspace = true } +tauri-plugin-opener = "2.5.4" tauri-plugin-os = "2.3.2" yaak-mac-window = { workspace = true } yaak-proxy-lib = { workspace = true } diff --git a/crates-tauri/yaak-app-proxy/capabilities/default.json b/crates-tauri/yaak-app-proxy/capabilities/default.json index f4ce6abd7..f6b586f42 100644 --- a/crates-tauri/yaak-app-proxy/capabilities/default.json +++ b/crates-tauri/yaak-app-proxy/capabilities/default.json @@ -4,6 +4,9 @@ "windows": ["*"], "permissions": [ "core:default", + "dialog:allow-save", + "opener:allow-default-urls", + "opener:allow-open-url", "os:allow-os-type", "core:window:allow-close", "core:window:allow-is-fullscreen", diff --git a/crates-tauri/yaak-app-proxy/macos/app.yaak.proxy.helper.plist b/crates-tauri/yaak-app-proxy/macos/app.yaak.proxy.helper.plist new file mode 100644 index 000000000..4f418df9b --- /dev/null +++ b/crates-tauri/yaak-app-proxy/macos/app.yaak.proxy.helper.plist @@ -0,0 +1,27 @@ + + + + + + Label + app.yaak.proxy.helper + BundleProgram + Contents/MacOS/yaak-proxy-helper + AssociatedBundleIdentifiers + + app.yaak.proxy + app.yaak.proxy.dev + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + StandardErrorPath + /Library/Logs/app.yaak.proxy.helper.log + + diff --git a/crates-tauri/yaak-app-proxy/macos/entitlements.plist b/crates-tauri/yaak-app-proxy/macos/entitlements.plist new file mode 100644 index 000000000..c59b3eb79 --- /dev/null +++ b/crates-tauri/yaak-app-proxy/macos/entitlements.plist @@ -0,0 +1,8 @@ + + + + + + + diff --git a/crates-tauri/yaak-app-proxy/src/lib.rs b/crates-tauri/yaak-app-proxy/src/lib.rs index d5d3b4acb..ac694c4b2 100644 --- a/crates-tauri/yaak-app-proxy/src/lib.rs +++ b/crates-tauri/yaak-app-proxy/src/lib.rs @@ -61,13 +61,30 @@ async fn rpc( pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_opener::init()) .plugin(yaak_mac_window::init()) .setup(|app| { let data_dir = app.path().app_data_dir().expect("no app data dir"); std::fs::create_dir_all(&data_dir).expect("failed to create app data dir"); let (emitter, event_rx) = RpcEventEmitter::new(); - app.manage(ProxyCtx::new(&data_dir.join("proxy.db"), emitter)); + let ctx = ProxyCtx::new(&data_dir, emitter)?; + // Not while the CLI daemon owns the engine: the window offers to take it over. + match ctx.serve_control_api() { + // The engine runs for as long as the app does. A failure, such as the port + // being taken, is kept for the window to show. + Ok(()) => { + let ctx = ctx.clone(); + std::thread::spawn(move || { + if let Err(e) = ctx.start() { + warn!("Failed to start the proxy: {e}"); + } + }); + } + Err(e) => warn!("Not serving the proxy control API: {e}"), + } + app.manage(ctx); app.manage(yaak_proxy_lib::build_router()); // Drain RPC events and forward as Tauri events @@ -86,6 +103,13 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while building yaak proxy tauri application") .run(|app_handle, event| { + if let RunEvent::Exit = event { + // Never leave the Mac pointed at a proxy that is gone. + let ctx = app_handle.state::(); + ctx.shutdown(); + ctx.stop_control_api(); + return; + } if let RunEvent::Ready = event { let config = CreateWindowConfig { url: "/", diff --git a/crates-tauri/yaak-app-proxy/src/main.rs b/crates-tauri/yaak-app-proxy/src/main.rs index 70fd252a4..a51f608be 100644 --- a/crates-tauri/yaak-app-proxy/src/main.rs +++ b/crates-tauri/yaak-app-proxy/src/main.rs @@ -1,5 +1,10 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + // Says what macOS reports about the privileged helper, without opening a window. + if std::env::args().any(|a| a == "--helper-status") { + println!("{}", yaak_proxy_lib::helper::diagnose()); + return; + } tauri_app_proxy_lib::run(); } diff --git a/crates-tauri/yaak-app-proxy/tauri.bundle.conf.json b/crates-tauri/yaak-app-proxy/tauri.bundle.conf.json new file mode 100644 index 000000000..b5feca0f4 --- /dev/null +++ b/crates-tauri/yaak-app-proxy/tauri.bundle.conf.json @@ -0,0 +1,16 @@ +{ + "build": { + "beforeBuildCommand": "npm --prefix ../.. run proxy:tauri-before-bundle" + }, + "bundle": { + "externalBin": ["vendored/helper/yaak-proxy-helper"], + "macOS": { + "minimumSystemVersion": "13.0", + "hardenedRuntime": true, + "entitlements": "macos/entitlements.plist", + "files": { + "Library/LaunchDaemons/app.yaak.proxy.helper.plist": "macos/app.yaak.proxy.helper.plist" + } + } + } +} diff --git a/crates-tauri/yaak-license/src/license.rs b/crates-tauri/yaak-license/src/license.rs index dcc3fd122..de4c3f6a8 100644 --- a/crates-tauri/yaak-license/src/license.rs +++ b/crates-tauri/yaak-license/src/license.rs @@ -9,7 +9,7 @@ use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev}; use ts_rs::TS; use yaak_api::{ApiClientKind, yaak_api_client}; use yaak_common::platform::get_os_str; -use yaak_models::client_db::ClientDb; +use yaak_models::client_db::{ClientDb, WriteDb}; use yaak_models::query_manager::QueryManager; use yaak_models::util::UpdateSource; @@ -17,6 +17,10 @@ use yaak_models::util::UpdateSource; /// This is needed temporarily until all crates are refactored to not use Tauri. trait QueryManagerExt<'a, R> { fn db(&'a self) -> ClientDb<'a>; + fn with_tx( + &'a self, + func: impl FnOnce(&WriteDb) -> yaak_models::error::Result, + ) -> yaak_models::error::Result; } impl<'a, R: Runtime, M: Manager> QueryManagerExt<'a, R> for M { @@ -24,6 +28,14 @@ impl<'a, R: Runtime, M: Manager> QueryManagerExt<'a, R> for M { let qm = self.state::(); qm.inner().connect() } + + fn with_tx( + &'a self, + func: impl FnOnce(&WriteDb) -> yaak_models::error::Result, + ) -> yaak_models::error::Result { + let qm = self.state::(); + qm.inner().with_tx(func) + } } const KV_NAMESPACE: &str = "license"; @@ -137,12 +149,17 @@ pub async fn activate_license( } let body: ActivateLicenseResponsePayload = response.json().await?; - window.app_handle().db().set_key_value_str( - KV_ACTIVATION_ID_KEY, - KV_NAMESPACE, - body.activation_id.as_str(), - &UpdateSource::from_window_label(window.label()), - ); + if let Err(e) = window.app_handle().with_tx(|tx| { + tx.set_key_value_str( + KV_ACTIVATION_ID_KEY, + KV_NAMESPACE, + body.activation_id.as_str(), + &UpdateSource::from_window_label(window.label()), + ); + Ok(()) + }) { + warn!("Failed to store license activation: {e}"); + } if let Err(e) = window.emit("license-activated", true) { warn!("Failed to emit check-license event: {}", e); @@ -172,11 +189,13 @@ pub async fn deactivate_license(window: &WebviewWindow) -> Result return Err(ServerError); } - app_handle.db().delete_key_value( - KV_ACTIVATION_ID_KEY, - KV_NAMESPACE, - &UpdateSource::from_window_label(window.label()), - )?; + app_handle.with_tx(|tx| { + tx.delete_key_value( + KV_ACTIVATION_ID_KEY, + KV_NAMESPACE, + &UpdateSource::from_window_label(window.label()), + ) + })?; if let Err(e) = app_handle.emit("license-deactivated", true) { warn!("Failed to emit deactivate-license event: {}", e); diff --git a/crates-tauri/yaak-mac-window/Cargo.toml b/crates-tauri/yaak-mac-window/Cargo.toml index 19a5655a9..31e0b0baa 100644 --- a/crates-tauri/yaak-mac-window/Cargo.toml +++ b/crates-tauri/yaak-mac-window/Cargo.toml @@ -12,11 +12,11 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-cli tauri-plugin = { workspace = true, features = ["build"] } [target.'cfg(target_os = "macos")'.dependencies] -cocoa = "0.26.0" +cocoa = "0.27.0" log = { workspace = true } objc = "0.2.7" -rand = "0.9.0" -csscolorparser = "0.7.2" +rand = "0.10.2" +csscolorparser = "0.9.0" [dependencies] tauri = { workspace = true } diff --git a/crates-tauri/yaak-mac-window/src/mac.rs b/crates-tauri/yaak-mac-window/src/mac.rs index e45d0bd45..c4aee811e 100644 --- a/crates-tauri/yaak-mac-window/src/mac.rs +++ b/crates-tauri/yaak-mac-window/src/mac.rs @@ -147,7 +147,7 @@ pub fn setup_traffic_light_positioner(window: &Window) { use cocoa::delegate; use cocoa::foundation::NSUInteger; use objc::runtime::{Object, Sel}; - use rand::Rng; + use rand::RngExt; use rand::distr::Alphanumeric; use std::ffi::c_void; diff --git a/crates-tauri/yaak-proxy-helper/Cargo.toml b/crates-tauri/yaak-proxy-helper/Cargo.toml new file mode 100644 index 000000000..1c585d063 --- /dev/null +++ b/crates-tauri/yaak-proxy-helper/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "yaak-proxy-helper" +version = "0.0.0" +edition = "2024" +authors = ["Gregory Schier"] +publish = false + +# The library is the protocol and the `networksetup` logic, shared with `yaak-proxy-lib`, and +# builds everywhere. The daemon itself is macOS only. +[lib] +name = "yaak_proxy_helper" + +[[bin]] +name = "yaak-proxy-helper" +path = "src/main.rs" + +[features] +# TypeScript bindings for the types the proxy app shows. +ts = ["dep:ts-rs"] +# An in-memory `networksetup`, for tests that change the system proxy. +test-support = [] + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +ts-rs = { workspace = true, optional = true } + +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation = "0.10" +libc = "0.2" +security-framework = "3" + +[dev-dependencies] +tempfile = "3" diff --git a/crates-tauri/yaak-proxy-helper/src/auth.rs b/crates-tauri/yaak-proxy-helper/src/auth.rs new file mode 100644 index 000000000..afef2d43b --- /dev/null +++ b/crates-tauri/yaak-proxy-helper/src/auth.rs @@ -0,0 +1,121 @@ +//! Who is on the other end of the socket. The kernel says which process connected, by its +//! audit token, and Security.framework says whether that process is the proxy app signed by +//! [`crate::TEAM_ID`]. The token, unlike a pid, cannot be recycled for another process +//! between the two. + +use core_foundation::base::TCFType; +use core_foundation::data::CFData; +use security_framework::os::macos::code_signing::{ + Flags, GuestAttributes, SecCode, SecRequirement, +}; +use std::os::fd::AsRawFd; +use std::os::unix::net::UnixStream; +use std::str::FromStr; + +/// The process that connected. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Peer { + pub pid: i32, + /// Its `audit_token_t`. + pub audit_token: [u8; 32], +} + +impl Peer { + pub fn of(stream: &UnixStream) -> Result { + let fd = stream.as_raw_fd(); + let mut pid: libc::pid_t = 0; + let mut len = std::mem::size_of::() as libc::socklen_t; + // SAFETY: `pid` and `len` describe a buffer the size LOCAL_PEERPID writes. + let rc = unsafe { + libc::getsockopt( + fd, + libc::SOL_LOCAL, + libc::LOCAL_PEERPID, + (&mut pid as *mut libc::pid_t).cast(), + &mut len, + ) + }; + if rc != 0 { + return Err(format!( + "Failed to read the peer pid: {}", + std::io::Error::last_os_error() + )); + } + let mut audit_token = [0u8; 32]; + let mut len = audit_token.len() as libc::socklen_t; + // SAFETY: as above, for the 32-byte audit_token_t LOCAL_PEERTOKEN writes. + let rc = unsafe { + libc::getsockopt( + fd, + libc::SOL_LOCAL, + libc::LOCAL_PEERTOKEN, + audit_token.as_mut_ptr().cast(), + &mut len, + ) + }; + if rc != 0 || len as usize != audit_token.len() { + return Err(format!( + "Failed to read the peer's audit token: {}", + std::io::Error::last_os_error() + )); + } + Ok(Self { pid, audit_token }) + } +} + +/// Decides whether a peer may use the daemon. +pub trait Verifier: Send + Sync { + fn verify(&self, peer: &Peer) -> Result<(), String>; +} + +/// The real check: the peer's code signature against [`crate::code_requirement`]. +pub struct CodeSignature { + requirement: String, +} + +impl Default for CodeSignature { + fn default() -> Self { + Self { requirement: crate::code_requirement() } + } +} + +impl Verifier for CodeSignature { + fn verify(&self, peer: &Peer) -> Result<(), String> { + let refused = |why: String| format!("Refused pid {}: {why}", peer.pid); + let token = CFData::from_buffer(&peer.audit_token); + let mut attributes = GuestAttributes::new(); + attributes.set_audit_token(token.as_concrete_TypeRef()); + let code = SecCode::copy_guest_with_attribues(None, &attributes, Flags::NONE) + .map_err(|e| refused(format!("no code object ({e})")))?; + let requirement = SecRequirement::from_str(&self.requirement) + .map_err(|e| format!("Invalid code requirement: {e}"))?; + code.check_validity(Flags::NONE, &requirement) + .map_err(|e| refused(format!("not Yaak Proxy signed by {} ({e})", crate::TEAM_ID))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_the_peer_and_refuses_code_not_signed_as_the_app() { + // This test binary is the peer: at most ad-hoc signed, never the proxy app. + let (a, _b) = UnixStream::pair().unwrap(); + let peer = Peer::of(&a).unwrap(); + assert_eq!(peer.pid, std::process::id() as i32); + assert_ne!(peer.audit_token, [0; 32]); + let err = CodeSignature::default().verify(&peer).unwrap_err(); + assert!(err.starts_with(&format!("Refused pid {}", peer.pid)), "{err}"); + } + + #[test] + fn a_requirement_naming_another_team_refuses_too() { + let (a, _b) = UnixStream::pair().unwrap(); + let peer = Peer::of(&a).unwrap(); + let verifier = CodeSignature { + requirement: "anchor apple generic and identifier \"app.yaak.proxy\" and certificate leaf[subject.OU] = \"AAAAAAAAAA\"".into(), + }; + assert!(verifier.verify(&peer).is_err()); + } +} diff --git a/crates-tauri/yaak-proxy-helper/src/daemon.rs b/crates-tauri/yaak-proxy-helper/src/daemon.rs new file mode 100644 index 000000000..4d703c354 --- /dev/null +++ b/crates-tauri/yaak-proxy-helper/src/daemon.rs @@ -0,0 +1,783 @@ +//! The daemon: serves the socket, keeps the saved settings in its state file, and restores +//! them when the process that turned the system proxy on exits, crashes included. + +use crate::auth::{CodeSignature, Peer, Verifier}; +use crate::networksetup::{ + Networksetup, Snapshot, SystemNetworksetup, apply, disable_commands, enable_commands, + read_services, +}; +use crate::process::{Owner, Processes, SystemProcesses}; +use crate::protocol::{MAX_LINE, Request, Response, Status, parse_request}; +use serde::{Deserialize, Serialize}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +/// How often the owner is checked on. +const WATCH_INTERVAL: Duration = Duration::from_secs(1); + +/// Long enough for `networksetup` on every service, short enough that a silent caller lets go. +const IO_TIMEOUT: Duration = Duration::from_secs(30); + +/// What the daemon has done, saved to its state file before each change, so a daemon that +/// crashes or restarts still knows what to put back. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct State { + pub active: Option, + /// Saved settings a restore found no service for, a service removed or unplugged, kept + /// for when it is back and merged into the next capture. + #[serde(default)] + pub parked: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Active { + pub owner: Owner, + pub snapshot: Snapshot, +} + +pub struct Daemon { + ns: N, + processes: P, + state_path: PathBuf, + state: Mutex, +} + +impl Daemon { + /// Starts from the state file at `state_path`, or from nothing. + pub fn open(ns: N, processes: P, state_path: PathBuf) -> Self { + let state = match std::fs::read(&state_path) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| { + log(&format!("Ignoring an unreadable state file: {e}")); + State::default() + }), + Err(_) => State::default(), + }; + Self { ns, processes, state_path, state: Mutex::new(state) } + } + + pub fn state(&self) -> State { + self.lock().clone() + } + + pub fn handle(&self, request: &Request) -> Response { + let result = match *request { + Request::Get => Ok(status(&self.lock())), + Request::Set { enabled: true, port, owner_pid } => self.enable(port, owner_pid), + Request::Set { enabled: false, port, .. } => self.restore(Some(port)), + Request::Restore => self.restore(None), + Request::Unregister => self.unregister(), + }; + match result { + Ok(status) => Response::ok(status), + Err(e) => Response::error(e), + } + } + + /// Points every enabled service at `port`, saving what it replaces first, unless an + /// earlier enable already did and nothing was restored since. + fn enable(&self, port: u16, owner_pid: i32) -> Result { + let owner = self + .processes + .owner(owner_pid) + .ok_or_else(|| format!("The owner, pid {owner_pid}, is not running"))?; + let mut state = self.lock(); + let services = read_services(&self.ns, false)?; + let mut snapshot = match state.active.take() { + Some(active) => active.snapshot, + None => state.parked.take().unwrap_or_default(), + }; + snapshot.extend(&services, port); + state.active = Some(Active { owner, snapshot }); + // Saved before anything changes, so whatever happens next can be undone. + self.save(&state)?; + if let Err(e) = apply(&self.ns, &enable_commands(&services, port)) { + // Half applied is worse than not at all. + if let Err(undo) = self.restore_locked(&mut state, None) { + log(&format!("Failed to undo a partial change: {undo}")); + } + return Err(e); + } + Ok(status(&state)) + } + + /// Turns the system proxy off at `port`, or wherever it points without one: at the saved + /// port this puts back everything saved; at another, a stale one, it only turns off what + /// points there, and the saved settings stay with the routing still live. + fn restore(&self, port: Option) -> Result { + let mut state = self.lock(); + self.restore_locked(&mut state, port)?; + Ok(status(&state)) + } + + fn restore_locked(&self, state: &mut State, port: Option) -> Result<(), String> { + let port = port.unwrap_or(0); + let snapshot = state.active.as_ref().map(|a| &a.snapshot); + if snapshot.is_none() && port == 0 { + return Ok(()); + } + let services = read_services(&self.ns, true)?; + let (commands, used_up) = disable_commands(&services, snapshot, port); + // Saved first, so a restore cut short is finished by the next one. + if let Some(active) = state.active.as_mut() { + active.snapshot.mark_restoring(&commands, &services); + self.save(state)?; + } + // Kept on failure, so the next try still knows what to put back. + apply(&self.ns, &commands)?; + if used_up { + if let Some(active) = state.active.take() + && let Some(leftover) = active.snapshot.leftover(&services) + { + let names: Vec<&str> = leftover.services.iter().map(|s| s.name.as_str()).collect(); + log(&format!("Keeping saved settings no service matched: {}", names.join(", "))); + state.parked = Some(leftover); + } + } else if let Some(active) = state.active.as_mut() { + active.snapshot.restoring.clear(); + } + self.save(state) + } + + /// Restores, then deletes the state file, as the app is about to remove the daemon. Saved + /// settings no service matched stay in it, for a daemon registered again. + fn unregister(&self) -> Result { + let mut state = self.lock(); + self.restore_locked(&mut state, None)?; + if state.parked.is_some() { + return Ok(status(&state)); + } + match std::fs::remove_file(&self.state_path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("Failed to delete the state file: {e}")), + } + Ok(status(&state)) + } + + /// One look at the owner: restores once it has exited. True when it restored. + pub fn watch(&self) -> bool { + let mut state = self.lock(); + let Some(active) = &state.active else { + return false; + }; + if self.processes.is_alive(&active.owner) { + return false; + } + log(&format!("The owner, pid {}, exited; restoring the system proxy", active.owner.pid)); + match self.restore_locked(&mut state, None) { + Ok(()) => true, + Err(e) => { + log(&format!("Failed to restore the system proxy: {e}")); + false + } + } + } + + fn save(&self, state: &State) -> Result<(), String> { + let json = serde_json::to_vec_pretty(state).map_err(|e| e.to_string())?; + let tmp = self.state_path.with_extension("tmp"); + std::fs::write(&tmp, json) + .and_then(|()| std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))) + .and_then(|()| std::fs::rename(&tmp, &self.state_path)) + .map_err(|e| format!("Failed to save the helper's state: {e}")) + } + + fn lock(&self) -> MutexGuard<'_, State> { + // A panic mid-change leaves the state as saved, which is still the truth to restore. + self.state.lock().unwrap_or_else(|e| e.into_inner()) + } +} + +fn status(state: &State) -> Status { + Status { + active: state.active.is_some(), + port: state.active.as_ref().map(|a| a.snapshot.port), + owner_pid: state.active.as_ref().map(|a| a.owner.pid), + version: env!("CARGO_PKG_VERSION").to_string(), + } +} + +/// How many connections are served at once. The app needs one; more are refused. +pub const MAX_CONNECTIONS: usize = 4; + +/// A counting semaphore for connections being served. +pub struct Slots { + taken: Arc, + max: usize, +} + +/// A connection's place among [`Slots`], given back when dropped. +pub struct Slot(Arc); + +impl Drop for Slot { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } +} + +impl Slots { + pub fn new(max: usize) -> Self { + Self { taken: Arc::new(AtomicUsize::new(0)), max } + } + + /// A slot, unless all are taken. + pub fn try_take(&self) -> Option { + let max = self.max; + self.taken + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| (n < max).then_some(n + 1)) + .ok() + .map(|_| Slot(self.taken.clone())) + } +} + +/// Takes a connection on, before any thread is spawned for it: a free slot, and the kernel's +/// word on who is calling. Both are cheap; the signature check is not, so it runs in the +/// connection's own thread. +pub fn admit(stream: &UnixStream, slots: &Slots) -> Result<(Peer, Slot), String> { + let slot = slots.try_take().ok_or("The helper is busy; try again")?; + let peer = Peer::of(stream)?; + Ok((peer, slot)) +} + +/// Answers a connection that was not admitted with why, briefly, before it is closed. +fn refuse(stream: &UnixStream, why: &str) { + let _ = stream.set_write_timeout(Some(Duration::from_secs(1))); + let mut line = serde_json::to_string(&Response::error(why)).unwrap_or_default(); + line.push('\n'); + let mut writer = stream; + let _ = writer.write_all(line.as_bytes()); +} + +/// Answers one connection's requests, one line each, once the peer passes `verifier`. An +/// unregister leaves the daemon running: it stops when macOS confirms the unregistration, and +/// if that never comes, it is still there to answer. +pub fn serve_connection( + stream: UnixStream, + peer: Peer, + daemon: &Daemon, + verifier: &dyn Verifier, +) { + let _ = stream.set_read_timeout(Some(IO_TIMEOUT)); + let _ = stream.set_write_timeout(Some(IO_TIMEOUT)); + let mut writer = &stream; + let mut reply = |response: &Response| { + let mut line = serde_json::to_string(response).unwrap_or_default(); + line.push('\n'); + writer.write_all(line.as_bytes()).is_ok() + }; + + if let Err(e) = verifier.verify(&peer) { + log(&e); + reply(&Response::error(e)); + return; + } + + let mut reader = BufReader::new(&stream); + loop { + let mut line = String::new(); + match (&mut reader).take(MAX_LINE as u64).read_line(&mut line) { + Ok(0) => return, + Ok(_) if !line.ends_with('\n') => { + reply(&Response::error("Request too long or unterminated")); + return; + } + Ok(_) => {} + Err(_) => return, + } + let response = match parse_request(&line) { + Ok(request) => { + log(&format!("pid {}: {request:?}", peer.pid)); + daemon.handle(&request) + } + Err(e) => Response::error(e), + }; + if !reply(&response) { + return; + } + } +} + +/// The daemon's entry point, run by launchd as root. Returns the exit code. +pub fn run() -> i32 { + // SAFETY: geteuid has no preconditions. + if unsafe { libc::geteuid() } != 0 { + eprintln!("yaak-proxy-helper is started by launchd, as root"); + return 1; + } + // Blocked before any thread starts, so every thread inherits it and only `sigwait` sees + // them. + let signals = blocked_signals(); + + let state_dir = Path::new(crate::STATE_DIR); + if let Err(e) = std::fs::create_dir_all(state_dir) + .and_then(|()| std::fs::set_permissions(state_dir, std::fs::Permissions::from_mode(0o700))) + { + log(&format!("Failed to create {}: {e}", state_dir.display())); + return 1; + } + let daemon = + Arc::new(Daemon::open(SystemNetworksetup, SystemProcesses, state_dir.join("state.json"))); + // The owner may have exited while the daemon was down. + daemon.watch(); + + let _ = std::fs::remove_file(crate::SOCKET_PATH); + let listener = match UnixListener::bind(crate::SOCKET_PATH) { + Ok(listener) => listener, + Err(e) => { + log(&format!("Failed to listen on {}: {e}", crate::SOCKET_PATH)); + return 1; + } + }; + // Anyone may connect; only the signed app is answered. + let _ = std::fs::set_permissions(crate::SOCKET_PATH, std::fs::Permissions::from_mode(0o666)); + log(&format!("Listening on {}", crate::SOCKET_PATH)); + + let watched = daemon.clone(); + std::thread::spawn(move || { + loop { + std::thread::sleep(WATCH_INTERVAL); + watched.watch(); + } + }); + + let stopping = daemon.clone(); + std::thread::spawn(move || { + let mut signal = 0; + // SAFETY: `signals` is an initialised set, and `signal` a valid out pointer. + unsafe { libc::sigwait(&signals, &mut signal) }; + // Stopped by launchd: unregistered in System Settings, or the Mac shutting down. + log(&format!("Signal {signal}; restoring the system proxy and exiting")); + if let Response { ok: false, error: Some(e), .. } = stopping.handle(&Request::Restore) { + log(&format!("Failed to restore the system proxy: {e}")); + } + exit(); + }); + + let verifier: Arc = Arc::new(CodeSignature::default()); + let slots = Slots::new(MAX_CONNECTIONS); + for stream in listener.incoming() { + let Ok(stream) = stream else { + continue; + }; + let (peer, slot) = match admit(&stream, &slots) { + Ok(admitted) => admitted, + Err(why) => { + log(&why); + refuse(&stream, &why); + continue; + } + }; + let daemon = daemon.clone(); + let verifier = verifier.clone(); + let spawned = std::thread::Builder::new().spawn(move || { + let _slot = slot; + serve_connection(stream, peer, &daemon, verifier.as_ref()); + }); + // The closure, and with it the connection and its slot, is dropped: closed and freed. + if let Err(e) = spawned { + log(&format!("Failed to start a thread for pid {}: {e}", peer.pid)); + } + } + 0 +} + +/// Exits cleanly, which launchd does not restart. +fn exit() -> ! { + let _ = std::fs::remove_file(crate::SOCKET_PATH); + std::process::exit(0); +} + +fn blocked_signals() -> libc::sigset_t { + // SAFETY: the set is initialised by sigemptyset before use, and only signal numbers are + // added to it. + unsafe { + let mut set = std::mem::zeroed::(); + libc::sigemptyset(&mut set); + for signal in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP] { + libc::sigaddset(&mut set, signal); + } + libc::pthread_sigmask(libc::SIG_BLOCK, &set, std::ptr::null_mut()); + set + } +} + +fn log(message: &str) { + eprintln!( + "{} {message}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or_default() + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::networksetup::fake::FakeNetworksetup; + use crate::networksetup::{HOST, ProxySetting}; + use std::collections::HashMap; + + /// Processes that are alive while listed. + #[derive(Default)] + struct FakeProcesses(Mutex>); + + impl FakeProcesses { + fn start(&self, pid: i32, started: u64) { + self.0.lock().unwrap().insert(pid, started); + } + fn kill(&self, pid: i32) { + self.0.lock().unwrap().remove(&pid); + } + } + + impl Processes for FakeProcesses { + fn start_time(&self, pid: i32) -> Option { + self.0.lock().unwrap().get(&pid).copied() + } + } + + struct Allow; + impl Verifier for Allow { + fn verify(&self, _peer: &Peer) -> Result<(), String> { + Ok(()) + } + } + + struct Refuse; + impl Verifier for Refuse { + fn verify(&self, peer: &Peer) -> Result<(), String> { + Err(format!("Refused pid {}: not Yaak Proxy", peer.pid)) + } + } + + fn setting(enabled: bool, server: &str, port: u16) -> ProxySetting { + ProxySetting { enabled, server: server.into(), port } + } + + fn daemon(dir: &Path) -> Daemon { + let ns = FakeNetworksetup::new(vec![ + ("Wi-Fi", setting(true, "corp-proxy", 3128), setting(false, "corp-proxy", 3129)), + ("Ethernet", ProxySetting::default(), ProxySetting::default()), + ]); + let processes = FakeProcesses::default(); + processes.start(42, 1000); + Daemon::open(ns, processes, dir.join("state.json")) + } + + fn set(enabled: bool, port: u16) -> Request { + Request::Set { enabled, port, owner_pid: 42 } + } + + fn all_point_at(daemon: &Daemon, port: u16) -> bool { + daemon.ns.proxies().iter().all(|s| s.http.points_at(port) && s.https.points_at(port)) + } + + #[test] + fn set_then_off_restores_exactly() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let before = daemon.ns.proxies(); + + let status = daemon.handle(&set(true, 9090)).into_result().unwrap(); + assert!(status.active && status.port == Some(9090) && status.owner_pid == Some(42)); + assert!(all_point_at(&daemon, 9090)); + + let status = daemon.handle(&set(false, 9090)).into_result().unwrap(); + assert!(!status.active); + assert_eq!(daemon.ns.proxies()[0], before[0]); + assert!(!daemon.ns.proxies()[1].points_at(9090)); + } + + #[test] + fn the_snapshot_survives_a_daemon_restart() { + let dir = tempfile::tempdir().unwrap(); + let first = daemon(dir.path()); + let before = first.ns.proxies(); + first.handle(&set(true, 9090)).into_result().unwrap(); + let saved = first.state(); + assert!(saved.active.is_some()); + + // A new daemon over the same machine reads what the first saved. + let second = Daemon::open( + FakeNetworksetup::default(), + FakeProcesses::default(), + dir.path().join("state.json"), + ); + assert_eq!(second.state(), saved); + *second.ns.services.lock().unwrap() = first.ns.services.lock().unwrap().clone(); + second.handle(&Request::Restore).into_result().unwrap(); + assert_eq!(second.ns.proxies()[0], before[0]); + assert_eq!(second.state(), State::default()); + } + + #[test] + fn enabling_twice_keeps_the_first_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let before = daemon.ns.proxies(); + daemon.handle(&set(true, 9090)).into_result().unwrap(); + // The engine moved ports; what was saved is still the user's. + daemon.handle(&set(true, 9191)).into_result().unwrap(); + assert!(all_point_at(&daemon, 9191)); + daemon.handle(&Request::Restore).into_result().unwrap(); + assert_eq!(daemon.ns.proxies()[0], before[0]); + } + + #[test] + fn a_failed_port_move_still_restores_the_old_port() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let before = daemon.ns.proxies(); + daemon.handle(&set(true, 9090)).into_result().unwrap(); + // Moving to 9191 fails on Ethernet, after Wi-Fi moved. + *daemon.ns.failing.lock().unwrap() = Some("Ethernet".into()); + assert!(!daemon.handle(&set(true, 9191)).ok); + // The undo failed on Ethernet too, so what to put back is kept for the next try. + assert!(daemon.state().active.is_some()); + *daemon.ns.failing.lock().unwrap() = None; + daemon.handle(&Request::Restore).into_result().unwrap(); + // Restored everywhere, the old port included. + assert_eq!(daemon.ns.proxies()[0], before[0]); + assert!(!daemon.ns.proxies()[1].points_at(9090)); + assert_eq!(daemon.state(), State::default()); + } + + #[test] + fn turning_off_a_stale_port_keeps_the_live_one() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let before = daemon.ns.proxies(); + daemon.handle(&set(true, 9090)).into_result().unwrap(); + daemon.ns.set_enabled("Ethernet", false); + daemon.handle(&set(true, 9191)).into_result().unwrap(); + + // The app's launch cleanup of its old port. + let status = daemon.handle(&set(false, 9090)).into_result().unwrap(); + assert!(status.active && status.port == Some(9191)); + assert!(daemon.ns.proxies()[0].http.points_at(9191)); + assert!(!daemon.ns.proxies()[1].points_at(9090)); + + daemon.handle(&set(false, 9191)).into_result().unwrap(); + assert_eq!(daemon.ns.proxies()[0], before[0]); + } + + #[test] + fn a_service_renamed_while_routing_is_restored() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let before = daemon.ns.proxies(); + daemon.handle(&set(true, 9090)).into_result().unwrap(); + daemon.ns.rename("Wi-Fi", "Office Wi-Fi"); + daemon.handle(&set(false, 9090)).into_result().unwrap(); + let after = &daemon.ns.proxies()[0]; + assert_eq!((&after.http, &after.https), (&before[0].http, &before[0].https)); + assert_eq!(daemon.state(), State::default()); + } + + #[test] + fn settings_no_service_matched_are_parked_and_merged_back() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let wifi = daemon.ns.proxies()[0].clone(); + daemon.handle(&set(true, 9090)).into_result().unwrap(); + // Wi-Fi is unplugged while ours is on. + daemon.ns.remove("Wi-Fi"); + daemon.handle(&set(false, 9090)).into_result().unwrap(); + let parked = daemon.state().parked.unwrap(); + assert_eq!(parked.services.len(), 1); + assert_eq!(parked.services[0].http, wifi.http); + + // Kept through unregister, and back in the next capture. + daemon.handle(&Request::Unregister).into_result().unwrap(); + assert!(dir.path().join("state.json").exists()); + daemon.handle(&set(true, 9090)).into_result().unwrap(); + let state = daemon.state(); + assert!(state.parked.is_none()); + assert!(state.active.unwrap().snapshot.services.iter().any(|s| s.http == wifi.http)); + } + + #[test] + fn the_watchdog_restores_once_the_owner_exits() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let before = daemon.ns.proxies(); + daemon.handle(&set(true, 9090)).into_result().unwrap(); + + assert!(!daemon.watch(), "the owner is alive"); + assert!(all_point_at(&daemon, 9090)); + + daemon.processes.kill(42); + assert!(daemon.watch()); + assert_eq!(daemon.ns.proxies()[0], before[0]); + assert!(!daemon.watch(), "nothing left to restore"); + } + + #[test] + fn the_watchdog_treats_a_reused_pid_as_gone() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + daemon.handle(&set(true, 9090)).into_result().unwrap(); + // The owner exited and another process got its pid. + daemon.processes.start(42, 2000); + assert!(daemon.watch()); + assert!(!daemon.ns.proxies()[1].points_at(9090)); + } + + #[test] + fn refuses_an_owner_that_is_not_running() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let err = daemon.handle(&Request::Set { enabled: true, port: 9090, owner_pid: 7 }); + assert!(!err.ok); + assert!(daemon.ns.changes.lock().unwrap().is_empty()); + } + + #[test] + fn a_failed_enable_undoes_what_it_did() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let before = daemon.ns.proxies(); + // Ethernet goes away mid-change, after Wi-Fi was pointed at us. + *daemon.ns.failing.lock().unwrap() = Some("Ethernet".into()); + assert!(!daemon.handle(&set(true, 9090)).ok); + assert_eq!(daemon.ns.proxies(), before); + assert_eq!(daemon.state(), State::default()); + } + + #[test] + fn unregister_restores_and_deletes_the_state_file() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + daemon.handle(&set(true, 9090)).into_result().unwrap(); + assert!(dir.path().join("state.json").exists()); + daemon.handle(&Request::Unregister).into_result().unwrap(); + assert!(!dir.path().join("state.json").exists()); + assert!(!daemon.ns.proxies()[0].points_at(9090)); + } + + #[test] + fn get_reports_without_changing_anything() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let status = daemon.handle(&Request::Get).into_result().unwrap(); + assert!(!status.active); + assert!(daemon.ns.changes.lock().unwrap().is_empty()); + } + + fn exchange(verifier: &dyn Verifier, lines: &[&str]) -> (Vec, Vec) { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let (server, mut client) = UnixStream::pair().unwrap(); + for line in lines { + client.write_all(format!("{line}\n").as_bytes()).unwrap(); + } + client.shutdown(std::net::Shutdown::Write).unwrap(); + let peer = Peer::of(&server).unwrap(); + serve_connection(server, peer, &daemon, verifier); + let mut out = String::new(); + client.read_to_string(&mut out).unwrap(); + let responses = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + let http = daemon.ns.proxies().into_iter().map(|s| s.http).collect(); + (responses, http) + } + + #[test] + fn serves_a_verified_peer_line_by_line() { + let pid = std::process::id() as i32; + let (responses, http) = exchange( + &AllowWithOwner(pid), + &[ + r#"{"cmd":"get"}"#, + r#"{"cmd":"set","enabled":true,"port":80,"owner_pid":42}"#, + r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":42}"#, + ], + ); + assert_eq!(responses.len(), 3); + assert!(responses[0].ok && !responses[1].ok && responses[2].ok); + assert!(http.iter().all(|s| s.server == HOST && s.port == 9090)); + } + + #[test] + fn refuses_an_unverified_peer_before_reading_anything() { + let (responses, http) = + exchange(&Refuse, &[r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":42}"#]); + assert_eq!(responses.len(), 1); + assert!(responses[0].error.as_deref().unwrap().starts_with("Refused pid")); + assert!(http.iter().all(|s| s.server != HOST)); + } + + #[test] + fn refuses_a_peer_with_the_real_check() { + // The test binary is not the signed app. + let (responses, http) = exchange(&CodeSignature::default(), &[r#"{"cmd":"restore"}"#]); + assert!(!responses[0].ok); + assert!(http.iter().all(|s| s.server != HOST)); + } + + #[test] + fn keeps_answering_after_unregister() { + // Should macOS fail to unregister it, the app can still reach it. + let (responses, _) = exchange( + &Allow, + &[ + r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":42}"#, + r#"{"cmd":"unregister"}"#, + r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":42}"#, + ], + ); + assert_eq!(responses.len(), 3); + assert!(responses.iter().all(|r| r.ok)); + } + + #[test] + fn slots_cap_connections_and_free_on_drop() { + let slots = Slots::new(2); + let a = slots.try_take().unwrap(); + let _b = slots.try_take().unwrap(); + assert!(slots.try_take().is_none()); + drop(a); + assert!(slots.try_take().is_some()); + } + + #[test] + fn refuses_a_connection_over_the_cap_with_a_short_reply() { + let slots = Slots::new(MAX_CONNECTIONS); + let held: Vec<_> = (0..MAX_CONNECTIONS).map(|_| slots.try_take().unwrap()).collect(); + let (server, mut client) = UnixStream::pair().unwrap(); + let why = admit(&server, &slots).err().unwrap(); + refuse(&server, &why); + drop(server); + let mut out = String::new(); + client.read_to_string(&mut out).unwrap(); + let response: Response = serde_json::from_str(out.trim()).unwrap(); + assert_eq!(response.error.as_deref(), Some("The helper is busy; try again")); + + // Admitted again once one is free, with the peer read before any thread runs. + drop(held); + let (server, _client) = UnixStream::pair().unwrap(); + let (peer, _slot) = admit(&server, &slots).unwrap(); + assert_eq!(peer.pid, std::process::id() as i32); + } + + #[test] + fn stops_reading_an_overlong_line() { + let long = format!(r#"{{"cmd":"get","x":"{}"}}"#, "a".repeat(MAX_LINE)); + let (responses, _) = exchange(&Allow, &[&long, r#"{"cmd":"get"}"#]); + assert_eq!(responses.len(), 1); + assert!(!responses[0].ok); + } + + /// Allows only the expected peer, this test process. + struct AllowWithOwner(i32); + impl Verifier for AllowWithOwner { + fn verify(&self, peer: &Peer) -> Result<(), String> { + if peer.pid == self.0 { Ok(()) } else { Err("wrong peer".into()) } + } + } +} diff --git a/crates-tauri/yaak-proxy-helper/src/lib.rs b/crates-tauri/yaak-proxy-helper/src/lib.rs new file mode 100644 index 000000000..28bac30c8 --- /dev/null +++ b/crates-tauri/yaak-proxy-helper/src/lib.rs @@ -0,0 +1,68 @@ +//! Yaak Proxy's privileged helper: a root launchd daemon, registered by the app through +//! `SMAppService`, that points this Mac's web proxies at the engine and puts the user's own +//! settings back afterwards, so toggling the system proxy never asks for a password. +//! +//! The app talks to it over a Unix socket, one JSON line per request ([`protocol`]). Only a +//! caller signed as the proxy app by [`TEAM_ID`] is served. + +pub mod networksetup; +pub mod protocol; + +#[cfg(target_os = "macos")] +pub mod auth; +#[cfg(target_os = "macos")] +pub mod daemon; +#[cfg(target_os = "macos")] +pub mod process; + +/// The launchd label, which is also the plist's name without `.plist`. +pub const LABEL: &str = "app.yaak.proxy.helper"; + +/// The plist inside the app, at `Contents/Library/LaunchDaemons/`. +pub const PLIST_NAME: &str = "app.yaak.proxy.helper.plist"; + +/// Created by the daemon, root-owned and world-writable: who may talk is decided per caller. +pub const SOCKET_PATH: &str = "/var/run/app.yaak.proxy.helper.sock"; + +/// The daemon's own state, root only: the settings to restore, and whose they are. +pub const STATE_DIR: &str = "/var/db/app.yaak.proxy.helper"; + +/// The Apple Developer Team that signs Yaak Proxy, set at build time with +/// `YAAK_PROXY_TEAM_ID`. +pub const TEAM_ID: &str = match option_env!("YAAK_PROXY_TEAM_ID") { + Some(team) => team, + None => "7PU3P6ELJ8", +}; + +/// The bundle identifiers of the apps served: the release app, and the dev app as well in a +/// debug build of the helper. +pub const APP_IDENTIFIERS: &[&str] = if cfg!(debug_assertions) { + &["app.yaak.proxy", "app.yaak.proxy.dev"] +} else { + &["app.yaak.proxy"] +}; + +/// The code requirement a caller must satisfy. +pub fn code_requirement() -> String { + let identifiers = APP_IDENTIFIERS + .iter() + .map(|id| format!("identifier \"{id}\"")) + .collect::>() + .join(" or "); + format!( + "anchor apple generic and ({identifiers}) and certificate leaf[subject.OU] = \"{TEAM_ID}\"" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn requires_the_app_signed_by_the_team() { + let requirement = code_requirement(); + assert!(requirement.starts_with("anchor apple generic and (identifier \"app.yaak.proxy\"")); + assert!(requirement.ends_with(&format!("certificate leaf[subject.OU] = \"{TEAM_ID}\""))); + assert_eq!(TEAM_ID.len(), 10); + } +} diff --git a/crates-tauri/yaak-proxy-helper/src/main.rs b/crates-tauri/yaak-proxy-helper/src/main.rs new file mode 100644 index 000000000..db7ff77cb --- /dev/null +++ b/crates-tauri/yaak-proxy-helper/src/main.rs @@ -0,0 +1,10 @@ +#[cfg(target_os = "macos")] +fn main() { + std::process::exit(yaak_proxy_helper::daemon::run()); +} + +#[cfg(not(target_os = "macos"))] +fn main() { + eprintln!("yaak-proxy-helper only runs on macOS"); + std::process::exit(1); +} diff --git a/crates-tauri/yaak-proxy-helper/src/networksetup.rs b/crates-tauri/yaak-proxy-helper/src/networksetup.rs new file mode 100644 index 000000000..a6571015d --- /dev/null +++ b/crates-tauri/yaak-proxy-helper/src/networksetup.rs @@ -0,0 +1,944 @@ +//! The macOS system proxy as `networksetup` sees it: the web and secure web proxies of each +//! network service. Reading needs no rights; changing needs an administrator, which the +//! daemon is and the app's password-dialog fallback borrows. +//! +//! Before pointing the services at `127.0.0.1:`, their settings are saved in a +//! [`Snapshot`]; turning off puts back exactly what was there, not just "off". + +use serde::{Deserialize, Serialize}; + +pub const NETWORKSETUP: &str = "/usr/sbin/networksetup"; + +/// Where the system proxy is always pointed. Never anywhere else, so a compromised caller can +/// at worst send this Mac's traffic to localhost. +pub const HOST: &str = "127.0.0.1"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export_to = "gen_rpc.ts"))] +#[serde(rename_all = "camelCase")] +pub struct ServiceProxy { + /// The network service, as System Settings names it: "Wi-Fi", "USB 10/100/1000 LAN". + pub name: String, + /// What stays the same when the service is renamed: its device ("en0"), or its hardware + /// port when it has none. Absent when `networksetup` lists neither. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts", ts(optional))] + pub id: Option, + pub http: ProxySetting, + pub https: ProxySetting, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export_to = "gen_rpc.ts"))] +#[serde(rename_all = "camelCase")] +pub struct ProxySetting { + pub enabled: bool, + pub server: String, + pub port: u16, +} + +impl ProxySetting { + pub fn points_at(&self, port: u16) -> bool { + self.enabled && self.server == HOST && self.port == port + } + + /// This setting as the user's own: one pointing at one of `ports` is ours, left behind, so + /// it is saved as off. + fn owned(&self, ports: &[u16]) -> Self { + let ours = ports.iter().any(|p| self.points_at(*p)); + Self { enabled: self.enabled && !ours, ..self.clone() } + } +} + +impl ServiceProxy { + /// A service known by its name only. + pub fn new(name: &str, http: ProxySetting, https: ProxySetting) -> Self { + Self { name: name.into(), id: None, http, https } + } + + /// Either proxy of the service sends traffic to `127.0.0.1:`. + pub fn points_at(&self, port: u16) -> bool { + self.http.points_at(port) || self.https.points_at(port) + } +} + +/// The settings of the services the system proxy changed, as they were before, and the port +/// it points them at. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Snapshot { + pub port: u16, + /// Ports it pointed at before moving to `port`, where a service it could not move, or a + /// disabled one, may still point. + #[serde(default)] + pub previous_ports: Vec, + pub services: Vec, + /// Proxies a restore has started putting the saved server back on, as `(saved service, + /// "http" or "https")`, keyed by the saved entry's name, which a rename doesn't change. + /// Setting a server turns the proxy on, so one of these found on with its saved server, + /// saved as off, is a restore that stopped halfway. + #[serde(default)] + pub restoring: Vec<(String, String)>, +} + +impl Snapshot { + /// Records the restores `commands` start over `services`, saved before running them. + pub fn mark_restoring(&mut self, commands: &[Vec], services: &[ServiceProxy]) { + let mut started = Vec::new(); + for args in commands { + let proto = match args.first().map(String::as_str) { + Some("-setwebproxy") => "http", + Some("-setsecurewebproxy") => "https", + _ => continue, + }; + let Some(name) = args.get(1) else { continue }; + let saved = services + .iter() + .find(|s| &s.name == name) + .and_then(|s| self.get(s, services)) + .map_or_else(|| name.clone(), |saved| saved.name.clone()); + started.push((saved, proto.to_string())); + } + for entry in started { + if !self.restoring.contains(&entry) { + self.restoring.push(entry); + } + } + } + + fn is_restoring(&self, name: &str, proto: &str) -> bool { + self.restoring.iter().any(|(n, p)| n == name && p == proto) + } + + pub fn capture(services: &[ServiceProxy], port: u16) -> Self { + let mut snapshot = Self::default(); + snapshot.extend(services, port); + snapshot + } + + /// Records `services` not saved yet, a network connected since the first capture, and + /// moves the snapshot to `port`. Those already saved keep the user's settings, not ours. + pub fn extend(&mut self, services: &[ServiceProxy], port: u16) { + if self.port != 0 && self.port != port && !self.previous_ports.contains(&self.port) { + self.previous_ports.push(self.port); + } + self.previous_ports.retain(|p| *p != port); + self.port = port; + let ports = self.ports(); + // Judged against the snapshot as it was, so an entry added here can't change how the + // services after it match. + let new: Vec = services + .iter() + .filter(|service| self.get(service, services).is_none()) + .map(|service| ServiceProxy { + name: service.name.clone(), + id: service.id.clone(), + http: service.http.owned(&ports), + https: service.https.owned(&ports), + }) + .collect(); + self.services.extend(new); + } + + /// Every port this snapshot has pointed services at, the current one first. + pub fn ports(&self) -> Vec { + std::iter::once(self.port).chain(self.previous_ports.iter().copied()).collect() + } + + /// What was saved for `service`, one of `services`, the network's services now: the entry + /// with its name, when the ids don't disagree, or, for a service renamed since, the entry + /// on its device whose name no service has any more. Services can share a device, so the + /// device decides only when it is unambiguous both ways: one such saved entry, and one + /// service on the device with no saved entry by name. Otherwise nothing matches, and the + /// saved entry is parked rather than given to the wrong service. + fn get(&self, service: &ServiceProxy, services: &[ServiceProxy]) -> Option<&ServiceProxy> { + let ids_agree = + |s: &ServiceProxy| s.id.is_none() || service.id.is_none() || s.id == service.id; + if let Some(saved) = self.services.iter().find(|s| s.name == service.name && ids_agree(s)) { + return Some(saved); + } + let id = service.id.as_ref()?; + let mut orphans = self + .services + .iter() + .filter(|s| s.id.as_ref() == Some(id) && !services.iter().any(|c| c.name == s.name)); + let saved = orphans.next()?; + let unclaimed = services + .iter() + .filter(|c| { + c.id.as_ref() == Some(id) && !self.services.iter().any(|s| s.name == c.name) + }) + .count(); + (orphans.next().is_none() && unclaimed == 1).then_some(saved) + } + + /// What is left once this snapshot is restored over `services`: the saved entries none of + /// them matched, a service removed or unplugged, kept so its settings are not lost. They + /// are parked at port 0, which routes nothing. + pub fn leftover(&self, services: &[ServiceProxy]) -> Option { + let unmatched: Vec = self + .services + .iter() + .filter(|saved| { + !services + .iter() + .any(|s| self.get(s, services).is_some_and(|m| std::ptr::eq(m, *saved))) + }) + .cloned() + .collect(); + if unmatched.is_empty() { + return None; + } + Some(Snapshot { services: unmatched, ..Snapshot::default() }) + } +} + +/// The calls that turn the system proxy off at `port`, and whether `snapshot` is used up by +/// them. At the snapshot's current port, or at port 0 meaning wherever it is, they put back +/// everything it saved. At any other port they put back only what points there, leaving the +/// snapshot to the routing still live on its own port. +pub fn disable_commands( + services: &[ServiceProxy], + snapshot: Option<&Snapshot>, + port: u16, +) -> (Vec>, bool) { + match snapshot { + Some(snapshot) if port == 0 || port == snapshot.port => { + let mut ports = snapshot.ports(); + if port != 0 && !ports.contains(&port) { + ports.push(port); + } + (restore_commands(services, Some(snapshot), &ports), true) + } + Some(snapshot) => (restore_commands(services, Some(snapshot), &[port]), false), + None if port == 0 => (Vec::new(), true), + None => (restore_commands(services, None, &[port]), true), + } +} + +/// The services from `networksetup -listallnetworkservices`, the disabled ones too with +/// `include_disabled`. The first line is a legend; a leading `*` marks a disabled service. +pub fn parse_services(output: &str, include_disabled: bool) -> Vec { + output + .lines() + .skip(1) + .map(str::trim) + .filter(|l| !l.is_empty() && (include_disabled || !l.starts_with('*'))) + .map(|l| l.trim_start_matches('*').to_string()) + .collect() +} + +/// Each service's id from `networksetup -listnetworkserviceorder`, as `(name, id)`: its device, +/// or `port:` when it has no device, as a VPN may not. +pub fn parse_service_ids(output: &str) -> Vec<(String, String)> { + let mut ids = Vec::new(); + let mut name = None; + for line in output.lines().map(str::trim) { + if let Some(rest) = line.strip_prefix("(Hardware Port:") { + let Some(name) = name.take() else { continue }; + let rest = rest.trim_end_matches(')'); + let (port, device) = rest.split_once(", Device:").unwrap_or((rest, "")); + let (port, device) = (port.trim(), device.trim()); + if !device.is_empty() { + ids.push((name, device.to_string())); + } else if !port.is_empty() { + ids.push((name, format!("port:{port}"))); + } + } else if line.starts_with('(') + && let Some((_, rest)) = line.split_once(") ") + { + // "(1) Wi-Fi", or "(*) Name" for a disabled service. + name = Some(rest.to_string()); + } + } + ids +} + +/// The output of `networksetup -getwebproxy` or `-getsecurewebproxy`. +pub fn parse_proxy(output: &str) -> ProxySetting { + let mut setting = ProxySetting::default(); + for line in output.lines() { + let Some((key, value)) = line.split_once(':') else { + continue; + }; + let value = value.trim(); + match key.trim() { + "Enabled" => setting.enabled = value.eq_ignore_ascii_case("yes"), + "Server" => setting.server = value.to_string(), + "Port" => setting.port = value.parse().unwrap_or_default(), + _ => {} + } + } + setting +} + +const WEB: [(&str, &str); 2] = [ + ("-setwebproxy", "-setwebproxystate"), + ("-setsecurewebproxy", "-setsecurewebproxystate"), +]; + +/// The `networksetup` calls that point every one of `services` at `127.0.0.1:`. +pub fn enable_commands(services: &[ServiceProxy], port: u16) -> Vec> { + let mut commands = Vec::new(); + for service in services { + for (set, state) in WEB { + let name = service.name.clone(); + commands.push(vec![set.into(), name.clone(), HOST.into(), port.to_string()]); + commands.push(vec![state.into(), name, "on".into()]); + } + } + commands +} + +/// The `networksetup` calls that put back what `snapshot` saved for each proxy of `services` +/// still pointing at one of `ports`, and turn off those it has nothing saved for. A proxy the +/// user has pointed elsewhere since is theirs, and left alone. +pub fn restore_commands( + services: &[ServiceProxy], + snapshot: Option<&Snapshot>, + ports: &[u16], +) -> Vec> { + let mut commands = Vec::new(); + for service in services { + let saved_service = snapshot.and_then(|s| s.get(service, services)); + let proxies = [ + (&service.http, saved_service.map(|s| &s.http)), + (&service.https, saved_service.map(|s| &s.https)), + ]; + for (((current, saved), (set, state)), proto) in + proxies.into_iter().zip(WEB).zip(["http", "https"]) + { + let name = service.name.clone(); + // An earlier restore set the user's server, which turned it on, and stopped + // before turning it back off. Marked by the saved entry, so a rename since + // doesn't hide it. + let restoring = saved_service + .zip(snapshot) + .is_some_and(|(saved, snapshot)| snapshot.is_restoring(&saved.name, proto)); + let unfinished = saved.is_some_and(|s| { + !s.enabled + && current.enabled + && current.server == s.server + && current.port == s.port + && restoring + }); + if unfinished { + commands.push(vec![state.into(), name, "off".into()]); + continue; + } + if !ports.iter().any(|p| current.points_at(*p)) { + continue; + } + match saved { + // Setting the server turns the proxy on, so its state follows. + Some(saved) if !saved.server.is_empty() && saved.port != 0 => { + let port = saved.port.to_string(); + commands.push(vec![set.into(), name.clone(), saved.server.clone(), port]); + commands.push(vec![state.into(), name, on_off(saved.enabled)]); + } + _ => commands.push(vec![state.into(), name, "off".into()]), + } + } + } + commands +} + +fn on_off(on: bool) -> String { + if on { "on" } else { "off" }.to_string() +} + +/// Runs `networksetup`, real or stubbed. +pub trait Networksetup { + /// Runs it with `args`, returning what it printed. + fn run(&self, args: &[String]) -> Result; +} + +/// `/usr/sbin/networksetup` itself. +pub struct SystemNetworksetup; + +impl Networksetup for SystemNetworksetup { + fn run(&self, args: &[String]) -> Result { + let output = std::process::Command::new(NETWORKSETUP) + .args(args) + .output() + .map_err(|e| format!("Failed to run {NETWORKSETUP}: {e}"))?; + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + format!("{NETWORKSETUP} exited with {}", output.status) + } else { + stderr + }); + } + // It reports some failures, such as an unknown service, on stdout with status 0. + if stdout.starts_with("** Error") { + return Err(stdout.trim().to_string()); + } + Ok(stdout) + } +} + +/// Every service and its proxies, the disabled ones too with `include_disabled`. +pub fn read_services( + ns: &impl Networksetup, + include_disabled: bool, +) -> Result, String> { + let list = ns.run(&["-listallnetworkservices".to_string()])?; + // Without ids, services are still matched by name. + let ids = ns + .run(&["-listnetworkserviceorder".to_string()]) + .map(|order| parse_service_ids(&order)) + .unwrap_or_default(); + parse_services(&list, include_disabled) + .into_iter() + .map(|name| { + let http = parse_proxy(&ns.run(&["-getwebproxy".into(), name.clone()])?); + let https = parse_proxy(&ns.run(&["-getsecurewebproxy".into(), name.clone()])?); + let id = ids.iter().find(|(n, _)| *n == name).map(|(_, id)| id.clone()); + Ok(ServiceProxy { name, id, http, https }) + }) + .collect() +} + +/// Runs `commands` in order, stopping at the first that fails. +pub fn apply(ns: &impl Networksetup, commands: &[Vec]) -> Result<(), String> { + for args in commands { + ns.run(args)?; + } + Ok(()) +} + +/// A `networksetup` that keeps its services in memory, for tests here and in the app. +#[cfg(any(test, feature = "test-support"))] +pub mod fake { + use super::*; + use std::sync::Mutex; + + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct FakeService { + pub proxy: ServiceProxy, + pub enabled: bool, + } + + #[derive(Default)] + pub struct FakeNetworksetup { + pub services: Mutex>, + /// Every change made, in order. + pub changes: Mutex>>, + /// A service whose changes fail, as one removed mid-change would. + pub failing: Mutex>, + } + + impl FakeNetworksetup { + /// Services on devices `en0`, `en1` and on, in order. + pub fn new(services: Vec<(&str, ProxySetting, ProxySetting)>) -> Self { + let services = services + .into_iter() + .enumerate() + .map(|(i, (name, http, https))| FakeService { + proxy: ServiceProxy { + name: name.into(), + id: Some(format!("en{i}")), + http, + https, + }, + enabled: true, + }) + .collect(); + Self { services: Mutex::new(services), ..Default::default() } + } + + /// Renames a service, as System Settings can; its device stays. + pub fn rename(&self, name: &str, to: &str) { + let mut services = self.services.lock().unwrap(); + services.iter_mut().find(|s| s.proxy.name == name).unwrap().proxy.name = to.into(); + } + + /// Moves a service onto `device`, which another can share, as a duplicated one does. + pub fn set_device(&self, name: &str, device: &str) { + let mut services = self.services.lock().unwrap(); + services.iter_mut().find(|s| s.proxy.name == name).unwrap().proxy.id = + Some(device.into()); + } + + /// Adds an enabled service on `device`. + pub fn add(&self, name: &str, device: &str, http: ProxySetting, https: ProxySetting) { + let mut proxy = ServiceProxy::new(name, http, https); + proxy.id = Some(device.into()); + self.services.lock().unwrap().push(FakeService { proxy, enabled: true }); + } + + /// Removes a service, as unplugging an adapter can. + pub fn remove(&self, name: &str) { + self.services.lock().unwrap().retain(|s| s.proxy.name != name); + } + + /// The services' proxies, disabled ones included. + pub fn proxies(&self) -> Vec { + self.services.lock().unwrap().iter().map(|s| s.proxy.clone()).collect() + } + + pub fn set_enabled(&self, name: &str, enabled: bool) { + let mut services = self.services.lock().unwrap(); + services.iter_mut().find(|s| s.proxy.name == name).unwrap().enabled = enabled; + } + + pub fn set_proxy(&self, name: &str, http: ProxySetting, https: ProxySetting) { + let mut services = self.services.lock().unwrap(); + let service = services.iter_mut().find(|s| s.proxy.name == name).unwrap(); + service.proxy.http = http; + service.proxy.https = https; + } + } + + impl Networksetup for FakeNetworksetup { + fn run(&self, args: &[String]) -> Result { + let mut services = self.services.lock().unwrap(); + if args.first().map(String::as_str) == Some("-listallnetworkservices") { + let mut out = + "An asterisk (*) denotes that a network service is disabled.\n".to_string(); + for s in services.iter() { + out.push_str(&format!( + "{}{}\n", + if s.enabled { "" } else { "*" }, + s.proxy.name + )); + } + return Ok(out); + } + if args.first().map(String::as_str) == Some("-listnetworkserviceorder") { + let mut out = + "An asterisk (*) denotes that a network service is disabled.\n".to_string(); + for (i, s) in services.iter().enumerate() { + let order = if s.enabled { (i + 1).to_string() } else { "*".into() }; + let device = s.proxy.id.clone().unwrap_or_default(); + out.push_str(&format!( + "({order}) {}\n(Hardware Port: {}, Device: {device})\n\n", + s.proxy.name, s.proxy.name + )); + } + return Ok(out); + } + let [command, name, rest @ ..] = args else { + return Err(format!("bad arguments {args:?}")); + }; + let service = services + .iter_mut() + .find(|s| &s.proxy.name == name) + .ok_or_else(|| format!("** Error: {name} is not a recognized network service."))?; + let setting = if command.contains("secure") { + &mut service.proxy.https + } else { + &mut service.proxy.http + }; + match (command.as_str(), rest) { + ("-getwebproxy" | "-getsecurewebproxy", []) => { + return Ok(format!( + "Enabled: {}\nServer: {}\nPort: {}\nAuthenticated Proxy Enabled: 0\n", + if setting.enabled { "Yes" } else { "No" }, + setting.server, + setting.port + )); + } + (_, [_, ..]) if self.failing.lock().unwrap().as_ref() == Some(name) => { + return Err(format!("** Error: {name} is not a recognized network service.")); + } + ("-setwebproxy" | "-setsecurewebproxy", [server, port]) => { + setting.server = server.clone(); + setting.port = port.parse().map_err(|_| format!("bad port {port}"))?; + setting.enabled = true; + } + ("-setwebproxystate" | "-setsecurewebproxystate", [state]) => { + setting.enabled = state == "on"; + } + _ => return Err(format!("bad arguments {args:?}")), + } + self.changes.lock().unwrap().push(args.to_vec()); + Ok(String::new()) + } + } +} + +#[cfg(test)] +mod tests { + use super::fake::FakeNetworksetup; + use super::*; + + fn setting(enabled: bool, server: &str, port: u16) -> ProxySetting { + ProxySetting { enabled, server: server.into(), port } + } + + fn ours(port: u16) -> ProxySetting { + setting(true, HOST, port) + } + + #[test] + fn parses_services() { + let output = "An asterisk (*) denotes that a network service is disabled.\nWi-Fi\n*Thunderbolt Bridge\nUSB 10/100/1000 LAN\n\n"; + assert_eq!(parse_services(output, false), vec!["Wi-Fi", "USB 10/100/1000 LAN"]); + // A disabled service can still hold a proxy setting pointing at us. + assert_eq!( + parse_services(output, true), + vec!["Wi-Fi", "Thunderbolt Bridge", "USB 10/100/1000 LAN"] + ); + } + + #[test] + fn parses_proxy() { + let on = "Enabled: Yes\nServer: 127.0.0.1\nPort: 9090\nAuthenticated Proxy Enabled: 0\n"; + assert_eq!(parse_proxy(on), ours(9090)); + let off = "Enabled: No\nServer: \nPort: 0\nAuthenticated Proxy Enabled: 0\n"; + assert_eq!(parse_proxy(off), setting(false, "", 0)); + } + + #[test] + fn parses_service_ids() { + let output = "An asterisk (*) denotes that a network service is disabled.\n(1) Wi-Fi\n(Hardware Port: Wi-Fi, Device: en0)\n\n(*) Thunderbolt Bridge\n(Hardware Port: Thunderbolt Bridge, Device: bridge0)\n\n(3) Tailscale\n(Hardware Port: io.tailscale.ipn.macsys, Device: )\n\n"; + assert_eq!( + parse_service_ids(output), + vec![ + ("Wi-Fi".to_string(), "en0".to_string()), + ("Thunderbolt Bridge".to_string(), "bridge0".to_string()), + ("Tailscale".to_string(), "port:io.tailscale.ipn.macsys".to_string()), + ] + ); + } + + #[test] + fn a_service_renamed_while_routing_gets_its_settings_back() { + let corp = setting(true, "corp-proxy", 3128); + let ns = FakeNetworksetup::new(vec![("Wi-Fi", corp.clone(), corp.clone())]); + let snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090); + assert_eq!(snapshot.services[0].id.as_deref(), Some("en0")); + apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap(); + + ns.rename("Wi-Fi", "Office Wi-Fi"); + let services = read_services(&ns, true).unwrap(); + let (commands, used_up) = disable_commands(&services, Some(&snapshot), 9090); + apply(&ns, &commands).unwrap(); + assert_eq!(ns.proxies()[0].http, corp); + assert_eq!(ns.proxies()[0].https, corp); + assert!(used_up && snapshot.leftover(&services).is_none()); + } + + #[test] + fn services_sharing_a_device_keep_their_own_settings() { + let corp = setting(true, "corp-proxy", 3128); + let lab = setting(true, "lab-proxy", 8888); + let ns = FakeNetworksetup::new(vec![ + ("Wi-Fi", corp.clone(), corp.clone()), + ("Wi-Fi Lab", lab.clone(), lab.clone()), + ]); + ns.set_device("Wi-Fi Lab", "en0"); + let before = ns.proxies(); + let snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090); + assert_eq!(snapshot.services.len(), 2); + apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap(); + + let services = read_services(&ns, true).unwrap(); + let (commands, _) = disable_commands(&services, Some(&snapshot), 9090); + apply(&ns, &commands).unwrap(); + assert_eq!(ns.proxies(), before); + assert!(snapshot.leftover(&services).is_none()); + } + + #[test] + fn a_rename_plus_a_new_service_on_its_device_matches_neither() { + let corp = setting(true, "corp-proxy", 3128); + let lab = setting(true, "lab-proxy", 8888); + let ns = FakeNetworksetup::new(vec![("Wi-Fi", corp.clone(), corp.clone())]); + let mut snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090); + apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap(); + // While routing: Wi-Fi is renamed, and a second service is added on its device. + ns.rename("Wi-Fi", "Office"); + ns.add("Lab", "en0", lab.clone(), lab.clone()); + + // Turned on again: Lab's own settings are saved, not skipped as Wi-Fi's. + let services = read_services(&ns, false).unwrap(); + snapshot.extend(&services, 9090); + assert!(snapshot.services.iter().any(|s| s.name == "Lab" && s.http == lab)); + apply(&ns, &enable_commands(&services, 9090)).unwrap(); + + let services = read_services(&ns, true).unwrap(); + let (commands, _) = disable_commands(&services, Some(&snapshot), 9090); + apply(&ns, &commands).unwrap(); + let proxies = ns.proxies(); + assert_eq!(proxies[1].http, lab, "Lab gets its own settings back"); + assert!(!proxies[0].points_at(9090)); + assert_ne!(proxies[0].http, corp, "ambiguous, so Office is not handed Wi-Fi's"); + // Wi-Fi's saved settings are not lost. + let leftover = snapshot.leftover(&services).unwrap(); + assert!(leftover.services.iter().any(|s| s.name == "Wi-Fi" && s.http == corp)); + } + + #[test] + fn a_rename_beside_a_duplicate_still_matches() { + let corp = setting(true, "corp-proxy", 3128); + let lab = setting(true, "lab-proxy", 8888); + let ns = FakeNetworksetup::new(vec![ + ("Wi-Fi", corp.clone(), corp.clone()), + ("Wi-Fi Lab", lab.clone(), lab.clone()), + ]); + ns.set_device("Wi-Fi Lab", "en0"); + let snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090); + apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap(); + ns.rename("Wi-Fi", "Office"); + + let services = read_services(&ns, true).unwrap(); + let (commands, _) = disable_commands(&services, Some(&snapshot), 9090); + apply(&ns, &commands).unwrap(); + assert_eq!(ns.proxies()[0].http, corp); + assert_eq!(ns.proxies()[1].http, lab); + assert!(snapshot.leftover(&services).is_none()); + } + + #[test] + fn a_saved_entry_no_service_matches_is_kept() { + let corp = setting(true, "corp-proxy", 3128); + let ns = FakeNetworksetup::new(vec![ + ("Wi-Fi", corp.clone(), corp.clone()), + ("USB LAN", corp.clone(), corp.clone()), + ]); + let snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090); + apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap(); + ns.remove("USB LAN"); + + let leftover = snapshot.leftover(&read_services(&ns, true).unwrap()).unwrap(); + assert_eq!(leftover.port, 0); + assert_eq!(leftover.services.len(), 1); + assert_eq!(leftover.services[0].name, "USB LAN"); + assert_eq!(leftover.services[0].http, corp); + } + + #[test] + fn a_same_named_service_on_another_device_is_not_matched() { + let corp = setting(true, "corp-proxy", 3128); + let mut saved = ServiceProxy::new("Ethernet", corp.clone(), corp); + saved.id = Some("en5".into()); + let snapshot = Snapshot { port: 9090, services: vec![saved], ..Snapshot::default() }; + let mut other = ServiceProxy::new("Ethernet", setting(true, HOST, 9090), ours(9090)); + other.id = Some("en6".into()); + assert_eq!( + restore_commands(&[other], Some(&snapshot), &[9090]), + vec![ + vec!["-setwebproxystate", "Ethernet", "off"], + vec!["-setsecurewebproxystate", "Ethernet", "off"], + ] + ); + } + + #[test] + fn enabling_points_every_service_at_localhost() { + let services = vec![ServiceProxy::new( + "Wi-Fi", + ProxySetting::default(), + ProxySetting::default(), + )]; + assert_eq!( + enable_commands(&services, 9090), + vec![ + vec!["-setwebproxy", "Wi-Fi", "127.0.0.1", "9090"], + vec!["-setwebproxystate", "Wi-Fi", "on"], + vec!["-setsecurewebproxy", "Wi-Fi", "127.0.0.1", "9090"], + vec!["-setsecurewebproxystate", "Wi-Fi", "on"], + ] + ); + } + + #[test] + fn snapshot_round_trip_puts_back_exactly_what_was_there() { + let corp = setting(true, "corp-proxy", 3128); + let parked = setting(false, "old-proxy", 8888); + let ns = FakeNetworksetup::new(vec![ + ("Wi-Fi", corp.clone(), parked.clone()), + ("Ethernet", ProxySetting::default(), ProxySetting::default()), + ]); + let before = ns.proxies(); + + let services = read_services(&ns, false).unwrap(); + let snapshot = Snapshot::capture(&services, 9090); + apply(&ns, &enable_commands(&services, 9090)).unwrap(); + assert!(ns.proxies().iter().all(|s| s.http.points_at(9090) && s.https.points_at(9090))); + + let restore = + restore_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), &[9090]); + apply(&ns, &restore).unwrap(); + let after = ns.proxies(); + assert_eq!(after[0], before[0]); + // A service that never had a server is turned off, keeping ours as the unused server. + assert!(!after[1].http.enabled && !after[1].https.enabled); + } + + #[test] + fn restoring_leaves_a_proxy_the_user_changed_since() { + let ns = FakeNetworksetup::new(vec![( + "Wi-Fi", + setting(true, "corp-proxy", 3128), + setting(true, "corp-proxy", 3128), + )]); + let services = read_services(&ns, false).unwrap(); + let snapshot = Snapshot::capture(&services, 9090); + apply(&ns, &enable_commands(&services, 9090)).unwrap(); + // The user points HTTPS somewhere else while ours is on. + ns.set_proxy("Wi-Fi", ours(9090), setting(true, "charles", 8888)); + + let restore = + restore_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), &[9090]); + assert_eq!( + restore, + vec![ + vec!["-setwebproxy", "Wi-Fi", "corp-proxy", "3128"], + vec!["-setwebproxystate", "Wi-Fi", "on"], + ] + ); + } + + #[test] + fn restoring_without_a_snapshot_turns_off_only_our_proxies() { + let services = vec![ + ServiceProxy::new("Wi-Fi", ours(9090), ours(9090)), + // Someone else's proxy, and ours on another port. + ServiceProxy::new("Ethernet", setting(true, "corp-proxy", 9090), ours(8080)), + ]; + assert!(services[0].points_at(9090) && !services[1].points_at(9090)); + assert_eq!( + restore_commands(&services, None, &[9090]), + vec![ + vec!["-setwebproxystate", "Wi-Fi", "off"], + vec!["-setsecurewebproxystate", "Wi-Fi", "off"], + ] + ); + } + + #[test] + fn a_capture_over_our_own_leftover_saves_it_as_off() { + // A crash left Wi-Fi pointing at us with nothing saved. + let services = vec![ServiceProxy::new("Wi-Fi", ours(9090), ours(9090))]; + let snapshot = Snapshot::capture(&services, 9090); + assert!(!snapshot.services[0].http.enabled && !snapshot.services[0].https.enabled); + let restore = restore_commands(&services, Some(&snapshot), &[9090]); + assert_eq!( + restore, + vec![ + vec!["-setwebproxy", "Wi-Fi", "127.0.0.1", "9090"], + vec!["-setwebproxystate", "Wi-Fi", "off"], + vec!["-setsecurewebproxy", "Wi-Fi", "127.0.0.1", "9090"], + vec!["-setsecurewebproxystate", "Wi-Fi", "off"], + ] + ); + } + + #[test] + fn extending_keeps_the_first_capture_and_adds_new_services() { + let corp = setting(true, "corp-proxy", 3128); + let mut snapshot = + Snapshot::capture(&[ServiceProxy::new("Wi-Fi", corp.clone(), corp.clone())], 9090); + // Turned on again on another port: Wi-Fi now shows ours, and Ethernet is new. + snapshot.extend( + &[ + ServiceProxy::new("Wi-Fi", ours(9090), ours(9090)), + ServiceProxy::new("Ethernet", corp.clone(), corp.clone()), + ], + 9191, + ); + assert_eq!(snapshot.port, 9191); + assert_eq!(snapshot.services.len(), 2); + assert_eq!(snapshot.services[0].http, corp); + assert_eq!(snapshot.services[1].https, corp); + } + + /// Enables on 9090 over `parked`, then runs only the first call of the restore, as a crash + /// would leave it: the saved server is back, but on. + fn cut_short(parked: &ProxySetting) -> (FakeNetworksetup, Snapshot) { + let ns = FakeNetworksetup::new(vec![("Wi-Fi", parked.clone(), parked.clone())]); + let mut snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090); + apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap(); + let restore = + restore_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), &[9090]); + snapshot.mark_restoring(&restore, &read_services(&ns, true).unwrap()); + apply(&ns, &restore[..1]).unwrap(); + assert!(ns.proxies()[0].http.enabled); + (ns, snapshot) + } + + #[test] + fn a_restore_cut_short_is_finished_on_the_next_try() { + let parked = setting(false, "old-proxy", 8888); + let (ns, snapshot) = cut_short(&parked); + let (retry, used_up) = + disable_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), 9090); + assert!(used_up); + apply(&ns, &retry).unwrap(); + assert_eq!(ns.proxies()[0].http, parked); + assert_eq!(ns.proxies()[0].https, parked); + } + + #[test] + fn a_cut_short_restore_is_finished_after_a_rename() { + let parked = setting(false, "old-proxy", 8888); + let (ns, snapshot) = cut_short(&parked); + ns.rename("Wi-Fi", "Office Wi-Fi"); + let (retry, _) = + disable_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), 9090); + apply(&ns, &retry).unwrap(); + assert_eq!(ns.proxies()[0].http, parked); + assert_eq!(ns.proxies()[0].https, parked); + } + + #[test] + fn a_cut_short_restore_of_a_localhost_proxy_is_finished_too() { + let parked = setting(false, HOST, 8888); + let (ns, snapshot) = cut_short(&parked); + let (retry, _) = + disable_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), 9090); + apply(&ns, &retry).unwrap(); + assert_eq!(ns.proxies()[0].http, parked); + } + + #[test] + fn a_proxy_the_user_turned_on_themselves_is_left_on() { + let parked = setting(false, "corp-proxy", 3128); + let ns = FakeNetworksetup::new(vec![ + ("Wi-Fi", ProxySetting::default(), ProxySetting::default()), + ("Ethernet", parked.clone(), parked.clone()), + ]); + let snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090); + apply(&ns, &enable_commands(&ns.proxies()[..1], 9090)).unwrap(); + // A VPN turns the parked corporate proxy on while ours runs on Wi-Fi. + let on = setting(true, "corp-proxy", 3128); + ns.set_proxy("Ethernet", on.clone(), on.clone()); + let (commands, _) = + disable_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), 9090); + apply(&ns, &commands).unwrap(); + assert_eq!(ns.proxies()[1].http, on); + } + + #[test] + fn clearing_an_old_port_puts_back_what_was_saved_there() { + let corp = setting(true, "corp-proxy", 3128); + let ns = FakeNetworksetup::new(vec![ + ("Wi-Fi", corp.clone(), corp.clone()), + ("Ethernet", corp.clone(), corp.clone()), + ]); + let mut snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090); + apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap(); + // Ethernet is disabled before the move to 9191, so it stays on 9090. + ns.set_enabled("Ethernet", false); + let enabled = read_services(&ns, false).unwrap(); + snapshot.extend(&enabled, 9191); + apply(&ns, &enable_commands(&enabled, 9191)).unwrap(); + + let (commands, used_up) = + disable_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), 9090); + assert!(!used_up); + apply(&ns, &commands).unwrap(); + assert_eq!(ns.proxies()[1].http, corp); + assert!(ns.proxies()[0].http.points_at(9191), "live routing kept"); + } + + #[test] + fn the_fake_refuses_an_unknown_service() { + let ns = FakeNetworksetup::new(vec![]); + assert!(ns.run(&["-getwebproxy".into(), "Nope".into()]).is_err()); + } +} diff --git a/crates-tauri/yaak-proxy-helper/src/process.rs b/crates-tauri/yaak-proxy-helper/src/process.rs new file mode 100644 index 000000000..d2bb81a1c --- /dev/null +++ b/crates-tauri/yaak-proxy-helper/src/process.rs @@ -0,0 +1,77 @@ +//! Telling whether the process that turned the system proxy on is still the same process. A +//! pid alone is not enough once it can be reused, after a reboot for one, so the owner is the +//! pid and the time it started. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Owner { + pub pid: i32, + /// Microseconds since the epoch. + pub started: u64, +} + +pub trait Processes { + /// When `pid` started, while it runs. + fn start_time(&self, pid: i32) -> Option; + + fn owner(&self, pid: i32) -> Option { + Some(Owner { pid, started: self.start_time(pid)? }) + } + + fn is_alive(&self, owner: &Owner) -> bool { + self.start_time(owner.pid) == Some(owner.started) + } +} + +/// This Mac's processes, through `proc_pidinfo`. +pub struct SystemProcesses; + +impl Processes for SystemProcesses { + fn start_time(&self, pid: i32) -> Option { + if pid <= 0 { + return None; + } + let mut info = std::mem::MaybeUninit::::zeroed(); + let size = std::mem::size_of::() as i32; + // SAFETY: `info` is a buffer of exactly `size` bytes for PROC_PIDTBSDINFO to fill. + let written = unsafe { + libc::proc_pidinfo(pid, libc::PROC_PIDTBSDINFO, 0, info.as_mut_ptr().cast(), size) + }; + if written != size { + return None; + } + // SAFETY: proc_pidinfo filled the whole struct. + let info = unsafe { info.assume_init() }; + // A zombie has exited; only its parent has yet to notice. + if info.pbi_status == libc::SZOMB { + return None; + } + Some(info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn knows_this_process_and_not_a_missing_one() { + let me = SystemProcesses.owner(std::process::id() as i32).unwrap(); + assert!(SystemProcesses.is_alive(&me)); + // Same pid, different start: the pid was reused. + assert!(!SystemProcesses.is_alive(&Owner { started: me.started + 1, ..me })); + assert_eq!(SystemProcesses.start_time(0), None); + assert_eq!(SystemProcesses.start_time(i32::MAX), None); + } + + #[test] + fn a_child_that_exited_is_not_alive() { + let mut child = std::process::Command::new("/usr/bin/true").spawn().unwrap(); + let owner = SystemProcesses.owner(child.id() as i32); + child.wait().unwrap(); + if let Some(owner) = owner { + assert!(!SystemProcesses.is_alive(&owner)); + } + } +} diff --git a/crates-tauri/yaak-proxy-helper/src/protocol.rs b/crates-tauri/yaak-proxy-helper/src/protocol.rs new file mode 100644 index 000000000..b3014c31f --- /dev/null +++ b/crates-tauri/yaak-proxy-helper/src/protocol.rs @@ -0,0 +1,151 @@ +//! What the app and the daemon say to each other: one JSON object per line each way, a +//! [`Request`] and its [`Response`]. + +use serde::{Deserialize, Serialize}; + +/// The lowest port the system proxy may point at: never a privileged one. +pub const MIN_PORT: u16 = 1024; + +/// The longest request line read, far more than any request needs. +pub const MAX_LINE: usize = 4096; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "cmd", rename_all = "snake_case", deny_unknown_fields)] +pub enum Request { + /// What the daemon has done to the system proxy. + Get, + /// Points every enabled service at `127.0.0.1:`, saving the settings it replaces, + /// and restores them once `owner_pid` exits. Off restores them now. + Set { + enabled: bool, + port: u16, + owner_pid: i32, + }, + /// Puts back the saved settings. + Restore, + /// Restores and deletes the daemon's state, before the app unregisters it, which stops it. + Unregister, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Status { + /// The daemon has pointed the system proxy at the engine and will restore it. + pub active: bool, + pub port: Option, + pub owner_pid: Option, + pub version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Response { + pub ok: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, +} + +impl Response { + pub fn ok(status: Status) -> Self { + Self { ok: true, error: None, status: Some(status) } + } + + pub fn error(message: impl Into) -> Self { + Self { ok: false, error: Some(message.into()), status: None } + } + + pub fn into_result(self) -> Result { + if self.ok { + Ok(self.status.unwrap_or_default()) + } else { + Err(self.error.unwrap_or_else(|| "The helper refused the request".to_string())) + } + } +} + +/// Reads and checks one request line. The host is not a field: it is always `127.0.0.1`. +pub fn parse_request(line: &str) -> Result { + let request: Request = + serde_json::from_str(line.trim()).map_err(|e| format!("Invalid request: {e}"))?; + if let Request::Set { enabled: true, port, owner_pid } = request { + if port < MIN_PORT { + return Err(format!("Port {port} is outside {MIN_PORT} to 65535")); + } + if owner_pid <= 0 { + return Err(format!("Invalid owner pid {owner_pid}")); + } + } + Ok(request) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_each_command() { + assert_eq!(parse_request(r#"{"cmd":"get"}"#), Ok(Request::Get)); + assert_eq!(parse_request("{\"cmd\":\"restore\"}\n"), Ok(Request::Restore)); + assert_eq!(parse_request(r#"{"cmd":"unregister"}"#), Ok(Request::Unregister)); + assert_eq!( + parse_request(r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":42}"#), + Ok(Request::Set { enabled: true, port: 9090, owner_pid: 42 }) + ); + } + + #[test] + fn refuses_ports_outside_1024_to_65535() { + for port in [0, 80, 1023] { + let line = format!(r#"{{"cmd":"set","enabled":true,"port":{port},"owner_pid":42}}"#); + assert!(parse_request(&line).is_err(), "port {port}"); + } + assert!( + parse_request(r#"{"cmd":"set","enabled":true,"port":65536,"owner_pid":42}"#).is_err() + ); + assert!(parse_request(r#"{"cmd":"set","enabled":true,"port":-1,"owner_pid":42}"#).is_err()); + assert!( + parse_request(r#"{"cmd":"set","enabled":true,"port":1024,"owner_pid":42}"#).is_ok() + ); + assert!( + parse_request(r#"{"cmd":"set","enabled":true,"port":65535,"owner_pid":42}"#).is_ok() + ); + } + + #[test] + fn refuses_a_host() { + let line = r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":42,"host":"10.0.0.1"}"#; + assert!(parse_request(line).unwrap_err().contains("host")); + } + + #[test] + fn refuses_a_bad_owner_and_junk() { + assert!( + parse_request(r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":0}"#).is_err() + ); + assert!(parse_request(r#"{"cmd":"set","enabled":true,"port":9090}"#).is_err()); + assert!(parse_request(r#"{"cmd":"rm -rf"}"#).is_err()); + assert!(parse_request("not json").is_err()); + assert!(parse_request("").is_err()); + } + + #[test] + fn turning_off_needs_no_valid_port() { + // Off restores whatever the daemon saved; the port only widens what counts as ours. + assert!(parse_request(r#"{"cmd":"set","enabled":false,"port":0,"owner_pid":0}"#).is_ok()); + } + + #[test] + fn responses_round_trip() { + let ok = Response::ok(Status { + active: true, + port: Some(9090), + owner_pid: Some(1), + version: "1".into(), + }); + let line = serde_json::to_string(&ok).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap(), ok); + assert_eq!(Response::error("no").into_result(), Err("no".to_string())); + } +} diff --git a/crates-tauri/yaak-system-appearance/Cargo.toml b/crates-tauri/yaak-system-appearance/Cargo.toml index 9bc646c32..aab49750c 100644 --- a/crates-tauri/yaak-system-appearance/Cargo.toml +++ b/crates-tauri/yaak-system-appearance/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" publish = false [target.'cfg(target_os = "linux")'.dependencies] -dark-light = "2.0.0" +dark-light = "3.0.0" [target.'cfg(target_os = "macos")'.dependencies] dispatch2 = "0.3.0" diff --git a/crates-tauri/yaak-window/Cargo.toml b/crates-tauri/yaak-window/Cargo.toml index 36cd25cd1..713f8db53 100644 --- a/crates-tauri/yaak-window/Cargo.toml +++ b/crates-tauri/yaak-window/Cargo.toml @@ -7,7 +7,7 @@ publish = false [dependencies] log = { workspace = true } md5 = "0.8.0" -rand = "0.9.0" +rand = "0.10.2" serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tauri = { workspace = true } diff --git a/crates/common/yaak-database/Cargo.toml b/crates/common/yaak-database/Cargo.toml index 9ccd43387..5fb06e287 100644 --- a/crates/common/yaak-database/Cargo.toml +++ b/crates/common/yaak-database/Cargo.toml @@ -8,7 +8,7 @@ publish = false chrono = { version = "0.4.38", features = ["serde"] } include_dir = "0.7" log = { workspace = true } -nanoid = "0.4.0" +nanoid = "0.5.0" 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"] } @@ -24,5 +24,5 @@ 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"] } +getrandom = { version = "0.3.4", features = ["wasm_js"] } uuid = { version = "1", features = ["js"] } diff --git a/crates/common/yaak-database/src/pool.rs b/crates/common/yaak-database/src/pool.rs index 863087da8..b50544161 100644 --- a/crates/common/yaak-database/src/pool.rs +++ b/crates/common/yaak-database/src/pool.rs @@ -21,7 +21,8 @@ //! 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). +//! call waits for the one writer connection, which the outer call holds, and +//! times out). #[cfg(not(target_arch = "wasm32"))] mod imp { diff --git a/crates/common/yaak-database/src/update_source.rs b/crates/common/yaak-database/src/update_source.rs index c378d5d3b..b23493042 100644 --- a/crates/common/yaak-database/src/update_source.rs +++ b/crates/common/yaak-database/src/update_source.rs @@ -20,7 +20,9 @@ impl UpdateSource { #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case", tag = "type")] pub enum ModelChangeEvent { - Upsert { created: bool }, + Upsert { + created: bool, + }, /// A delete for a workspace implies deletion of every model in that /// workspace — children are bulk-deleted without their own change rows or /// events, and consumers must prune the subtree themselves (the frontend diff --git a/crates/common/yaak-rpc-schema/bindings/gen_models.ts b/crates/common/yaak-rpc-schema/bindings/gen_models.ts index 618e12139..97dc98b11 100644 --- a/crates/common/yaak-rpc-schema/bindings/gen_models.ts +++ b/crates/common/yaak-rpc-schema/bindings/gen_models.ts @@ -445,6 +445,7 @@ export type Settings = { updateChannel: string; hideLicenseBadge: boolean; promptFeedback: boolean; + mcpServerEnabled: boolean; autoupdate: boolean; autoDownloadUpdates: boolean; checkNotifications: boolean; diff --git a/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts b/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts index 4a8dcf4da..0c83aa596 100644 --- a/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts +++ b/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts @@ -10,11 +10,24 @@ import type { BatchUpsertResult, ImportDestination, ImportPlan } from "./gen_uti import type { Tokens } from "./parser"; import type { ServerSentEvent } from "./sse"; +/** + * A coding agent the Agents settings pane can set up. + */ +export type AgentClient = "claude_code" | "codex" | "cursor" | "vscode" | "other"; + +/** + * Which setup steps an agent already has done on this machine. `None` means + * the pane cannot tell for that agent. + */ +export type AgentSetupStatus = { mcpServer: boolean | null, skill: boolean | null, }; + /** * What the frontend learns about the host it is talking to. */ export type AppMetaData = { isDev: boolean, version: string, cliVersion: string | null, name: string, appDataDir: string, appLogDir: string, vendoredPluginDir: string, defaultProjectDir: string, featureUpdater: boolean, featureLicense: boolean, }; +export type CmdAgentSetupStatusReq = { agent: AgentClient, }; + export type CmdCallFolderActionReq = { req: CallFolderActionRequest, }; export type CmdCallGrpcRequestActionReq = { req: CallGrpcRequestActionRequest, }; @@ -31,6 +44,8 @@ export type CmdCheckForUpdatesReq = Record; export type CmdCommitImportReq = { plan: ImportPlan, }; +export type CmdCreateExampleWorkspaceReq = Record; + export type CmdCurlToRequestReq = { command: string, workspaceId: string, }; export type CmdDecryptTemplateReq = { template: string, }; @@ -43,13 +58,15 @@ export type CmdDeleteAllHttpResponsesReq = { requestId: string, }; export type CmdDeleteSendHistoryReq = { workspaceId: string, }; +export type CmdDetectImportSourceReq = { filePath?: string, url?: string, }; + export type CmdDisableEncryptionReq = { workspaceId: string, }; export type CmdDismissNotificationReq = { notificationId: string, }; export type CmdEnableEncryptionReq = { workspaceId: string, }; -export type CmdExportDataReq = { exportPath: string, workspaceIds: Array, includePrivateEnvironments: boolean, }; +export type CmdExportDataReq = { workspaceIds: Array, includePrivateEnvironments: boolean, }; export type CmdFolderActionsReq = Record; @@ -143,12 +160,10 @@ export type CmdHttpResponseBodyPathReq = { responseId: string, }; export type CmdHttpResponseBodyReq = { responseId: string, filter: string | null, }; -export type CmdImportDataReq = { filePath: string, destination: ImportDestination, }; +export type CmdImportDataReq = { filePaths: Array, urls: Array, destination: ImportDestination, }; export type CmdImportSourcesForOriginReq = { filePath?: string, url?: string, }; -export type CmdImportUrlReq = { url: string, destination: ImportDestination, }; - export type CmdListImportSourcesReq = { workspaceId: string, }; export type CmdMetadataReq = Record; @@ -195,7 +210,7 @@ export type CmdSendHttpRequestReq = { environmentId: string | null, cookieJarId: export type CmdSetWorkspaceKeyReq = { workspaceId: string, key: string, }; -export type CmdSyncApplyReq = { syncOps: Array, syncDir: string, workspaceId: string, }; +export type CmdSyncApplyReq = { syncOps: Array, syncDir: string, workspaceId: string, fsOnly: boolean, }; export type CmdSyncCalculateFsReq = { dir: string, }; @@ -260,6 +275,6 @@ export type ModelsWebsocketEventsReq = { connectionId: string, }; export type ModelsWorkspaceModelsReq = { workspaceId: string | null, }; -export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_import_url: [CmdImportUrlReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, null], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_snapshot_request: [ModelsSnapshotRequestReq, ModelVersion], models_request_version: [ModelsRequestVersionReq, RequestVersionComparison], models_restore_request_version: [ModelsRestoreRequestVersionReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array], models_grpc_events: [ModelsGrpcEventsReq, Array], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfo], cmd_git_worktree_status: [CmdGitWorktreeStatusReq, GitWorktreeStatus], cmd_git_log: [CmdGitLogReq, Array], cmd_git_log_for_file: [CmdGitLogForFileReq, Array], cmd_git_file_diff_for_commit: [CmdGitFileDiffForCommitReq, GitFileDiff], cmd_git_initialize: [CmdGitInitializeReq, null], cmd_git_clone: [CmdGitCloneReq, CloneResult], cmd_git_commit: [CmdGitCommitReq, null], cmd_git_fetch_all: [CmdGitFetchAllReq, null], cmd_git_push: [CmdGitPushReq, PushResult], cmd_git_pull: [CmdGitPullReq, PullResult], cmd_git_pull_force_reset: [CmdGitPullForceResetReq, PullResult], cmd_git_pull_merge: [CmdGitPullMergeReq, PullResult], cmd_git_add: [CmdGitAddReq, null], cmd_git_unstage: [CmdGitUnstageReq, null], cmd_git_reset_changes: [CmdGitResetChangesReq, null], cmd_git_restore_files: [CmdGitRestoreFilesReq, null], cmd_git_restore_file_from_commit: [CmdGitRestoreFileFromCommitReq, null], cmd_git_add_credential: [CmdGitAddCredentialReq, null], cmd_git_remotes: [CmdGitRemotesReq, Array], cmd_git_add_remote: [CmdGitAddRemoteReq, GitRemote], cmd_git_rm_remote: [CmdGitRmRemoteReq, null], cmd_sync_calculate: [CmdSyncCalculateReq, Array], cmd_sync_calculate_fs: [CmdSyncCalculateFsReq, Array], cmd_sync_apply: [CmdSyncApplyReq, null], cmd_ws_delete_connections: [CmdWsDeleteConnectionsReq, null], cmd_ws_send: [CmdWsSendReq, WebsocketConnection], cmd_ws_close: [CmdWsCloseReq, WebsocketConnection], cmd_ws_connect: [CmdWsConnectReq, WebsocketConnection], cmd_plugins_search: [CmdPluginsSearchReq, PluginSearchResponse], cmd_plugins_install: [CmdPluginsInstallReq, null], cmd_plugins_install_from_directory: [CmdPluginsInstallFromDirectoryReq, Plugin], cmd_plugins_uninstall: [CmdPluginsUninstallReq, Plugin], cmd_plugin_init_errors: [CmdPluginInitErrorsReq, Array<[string, string]>], cmd_plugins_updates: [CmdPluginsUpdatesReq, PluginUpdatesResponse], cmd_plugins_update_all: [CmdPluginsUpdateAllReq, Array], cmd_git_watch_worktree_status: [CmdGitWatchWorktreeStatusReq, GitWatchResult], cmd_sync_watch: [CmdSyncWatchReq, WatchResult], }; +export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_agent_setup_status: [CmdAgentSetupStatusReq, AgentSetupStatus], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array], cmd_detect_import_source: [CmdDetectImportSourceReq, string], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, string], cmd_create_example_workspace: [CmdCreateExampleWorkspaceReq, BatchUpsertResult], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_snapshot_request: [ModelsSnapshotRequestReq, ModelVersion], models_request_version: [ModelsRequestVersionReq, RequestVersionComparison], models_restore_request_version: [ModelsRestoreRequestVersionReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array], models_grpc_events: [ModelsGrpcEventsReq, Array], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfo], cmd_git_worktree_status: [CmdGitWorktreeStatusReq, GitWorktreeStatus], cmd_git_log: [CmdGitLogReq, Array], cmd_git_log_for_file: [CmdGitLogForFileReq, Array], cmd_git_file_diff_for_commit: [CmdGitFileDiffForCommitReq, GitFileDiff], cmd_git_initialize: [CmdGitInitializeReq, null], cmd_git_clone: [CmdGitCloneReq, CloneResult], cmd_git_commit: [CmdGitCommitReq, null], cmd_git_fetch_all: [CmdGitFetchAllReq, null], cmd_git_push: [CmdGitPushReq, PushResult], cmd_git_pull: [CmdGitPullReq, PullResult], cmd_git_pull_force_reset: [CmdGitPullForceResetReq, PullResult], cmd_git_pull_merge: [CmdGitPullMergeReq, PullResult], cmd_git_add: [CmdGitAddReq, null], cmd_git_unstage: [CmdGitUnstageReq, null], cmd_git_reset_changes: [CmdGitResetChangesReq, null], cmd_git_restore_files: [CmdGitRestoreFilesReq, null], cmd_git_restore_file_from_commit: [CmdGitRestoreFileFromCommitReq, null], cmd_git_add_credential: [CmdGitAddCredentialReq, null], cmd_git_remotes: [CmdGitRemotesReq, Array], cmd_git_add_remote: [CmdGitAddRemoteReq, GitRemote], cmd_git_rm_remote: [CmdGitRmRemoteReq, null], cmd_sync_calculate: [CmdSyncCalculateReq, Array], cmd_sync_calculate_fs: [CmdSyncCalculateFsReq, Array], cmd_sync_apply: [CmdSyncApplyReq, boolean], cmd_ws_delete_connections: [CmdWsDeleteConnectionsReq, null], cmd_ws_send: [CmdWsSendReq, WebsocketConnection], cmd_ws_close: [CmdWsCloseReq, WebsocketConnection], cmd_ws_connect: [CmdWsConnectReq, WebsocketConnection], cmd_plugins_search: [CmdPluginsSearchReq, PluginSearchResponse], cmd_plugins_install: [CmdPluginsInstallReq, null], cmd_plugins_install_from_directory: [CmdPluginsInstallFromDirectoryReq, Plugin], cmd_plugins_uninstall: [CmdPluginsUninstallReq, Plugin], cmd_plugin_init_errors: [CmdPluginInitErrorsReq, Array<[string, string]>], cmd_plugins_updates: [CmdPluginsUpdatesReq, PluginUpdatesResponse], cmd_plugins_update_all: [CmdPluginsUpdateAllReq, Array], cmd_git_watch_worktree_status: [CmdGitWatchWorktreeStatusReq, GitWatchResult], cmd_sync_watch: [CmdSyncWatchReq, WatchResult], }; export type WatchResult = { unlistenEvent: string, }; diff --git a/crates/common/yaak-rpc-schema/bindings/gen_search.ts b/crates/common/yaak-rpc-schema/bindings/gen_search.ts index dd3c1ad72..04cb08acf 100644 --- a/crates/common/yaak-rpc-schema/bindings/gen_search.ts +++ b/crates/common/yaak-rpc-schema/bindings/gen_search.ts @@ -1,5 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PluginMetadata = { version: string, name: string, displayName: string, description: string | null, homepageUrl: string | null, repositoryUrl: string | null, }; +export type PluginMetadata = { version: string, name: string, displayName: string, description: string | null, homepageUrl: string | null, repositoryUrl: string | null, +/** + * Why the plugin should no longer be used, from `deprecated` in its package.json + */ +deprecated: string | null, }; -export type PluginVersion = { id: string, version: string, url: string, description: string | null, name: string, displayName: string, homepageUrl: string | null, repositoryUrl: string | null, checksum: string, readme: string | null, yanked: boolean, }; +export type PluginVersion = { id: string, version: string, url: string, description: string | null, name: string, displayName: string, homepageUrl: string | null, repositoryUrl: string | null, checksum: string, readme: string | null, yanked: boolean, deprecated: string | null, }; diff --git a/crates/common/yaak-rpc-schema/bindings/gen_sync.ts b/crates/common/yaak-rpc-schema/bindings/gen_sync.ts index 49b168b50..d0e16a4ee 100644 --- a/crates/common/yaak-rpc-schema/bindings/gen_sync.ts +++ b/crates/common/yaak-rpc-schema/bindings/gen_sync.ts @@ -3,4 +3,7 @@ import type { SyncModel, SyncState } from "./gen_models"; export type FsCandidate = { "type": "FsCandidate", model: SyncModel, relPath: string, checksum: string, }; -export type SyncOp = { "type": "fsCreate", model: SyncModel, } | { "type": "fsUpdate", model: SyncModel, state: SyncState, } | { "type": "fsDelete", state: SyncState, fs: FsCandidate | null, } | { "type": "dbCreate", fs: FsCandidate, } | { "type": "dbUpdate", state: SyncState, fs: FsCandidate, } | { "type": "dbDelete", model: SyncModel, state: SyncState, } | { "type": "ignorePrivate", model: SyncModel, }; +/** + * Outbound operations record the file candidate checksum, or `None` if it was absent. + */ +export type SyncOp = { "type": "fsCreate", model: SyncModel, fsChecksum: string | null, } | { "type": "fsUpdate", model: SyncModel, state: SyncState, fsChecksum: string | null, } | { "type": "fsDelete", state: SyncState, fs: FsCandidate | null, } | { "type": "dbCreate", fs: FsCandidate, } | { "type": "dbUpdate", model: SyncModel, state: SyncState, fs: FsCandidate, } | { "type": "dbDelete", model: SyncModel, state: SyncState, } | { "type": "ignorePrivate", model: SyncModel, }; diff --git a/crates/common/yaak-rpc-schema/bindings/gen_util.ts b/crates/common/yaak-rpc-schema/bindings/gen_util.ts index 49d52e9fa..77f4599bb 100644 --- a/crates/common/yaak-rpc-schema/bindings/gen_util.ts +++ b/crates/common/yaak-rpc-schema/bindings/gen_util.ts @@ -1,7 +1,21 @@ // 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, environments: Array, folders: Array, httpRequests: Array, grpcRequests: Array, websocketRequests: Array, }; +export type BatchUpsertResult = { + workspaces: Array; + environments: Array; + folders: Array; + httpRequests: Array; + grpcRequests: Array; + websocketRequests: Array; +}; export type ImportConflictResolution = "keep_mine" | "take_source"; @@ -11,38 +25,95 @@ export type ImportConflictResolution = "keep_mine" | "take_source"; * 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, }; +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 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, -/** - * 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, origin?: ImportOrigin, }; +export type ImportPlan = { + importer: string; + destination: ImportDestination; + resources: BatchUpsertResult; + warnings: Array; + /** + * 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; + origin?: ImportOrigin; + /** + * Individual origins in a combined import, each retaining its own reimport history. + */ + sources: Array; +}; -export type ImportPlanAction = "create" | "update" | "delete" | "unchanged" | "keep_local" | "conflict"; +export type ImportPlanAction = + | "create" + | "update" + | "delete" + | "unchanged" + | "keep_local" + | "conflict" + | "ignored"; + +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; +}; -export type ImportPlanItem = { action: ImportPlanAction, model: ImportResourceType, modelId: string, name: string, /** - * Planned parent folder ID for incoming resources; current parent for deletions. + * Extra context for an action that would otherwise be indistinguishable from its plain form. */ -parentId?: string, selected: boolean, resolution?: ImportConflictResolution, }; +export type ImportPlanReason = "moved_into_ignored_folder"; -export type ImportPlanWarning = { title: string, detail: string, }; +export type ImportPlanSource = { + importer: string; + origin: ImportOrigin; + workspaceId: string; + sourceKeys: { [key in string]?: string }; + linkedSourceId?: string; +}; + +export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel }; + +/** + * Whether a plan's note is something to know or something to think twice about. + */ +export type ImportPlanWarningLevel = "info" | "warning"; /** * The model types an import plan can contain. */ -export type ImportResourceType = "environment" | "folder" | "grpc_request" | "http_request" | "websocket_request" | "workspace"; +export type ImportResourceType = + | "environment" + | "folder" + | "grpc_request" + | "http_request" + | "websocket_request" + | "workspace"; diff --git a/crates/common/yaak-rpc-schema/src/lib.rs b/crates/common/yaak-rpc-schema/src/lib.rs index 62ff1c3da..7c1a4c795 100644 --- a/crates/common/yaak-rpc-schema/src/lib.rs +++ b/crates/common/yaak-rpc-schema/src/lib.rs @@ -29,11 +29,10 @@ use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesRe use yaak_plugins::events::{ CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest, CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, - GetFolderActionsResponse, GetGrpcRequestActionsResponse, - GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, - GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse, - GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse, - GetWorkspaceActionsResponse, JsonPrimitive, RenderPurpose, + GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse, + GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse, + GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse, + GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, JsonPrimitive, RenderPurpose, }; use yaak_plugins::plugin_meta::PluginMetadata; use yaak_sse::sse::ServerSentEvent; @@ -98,6 +97,35 @@ pub struct CmdSyncWatchReq { #[ts(export, export_to = "gen_rpc.ts")] pub struct CmdMetadataReq {} +/// A coding agent the Agents settings pane can set up. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_rpc.ts")] +pub enum AgentClient { + ClaudeCode, + Codex, + Cursor, + Vscode, + Other, +} + +#[derive(Debug, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct CmdAgentSetupStatusReq { + pub agent: AgentClient, +} + +/// Which setup steps an agent already has done on this machine. `None` means +/// the pane cannot tell for that agent. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct AgentSetupStatus { + pub mcp_server: Option, + pub skill: Option, +} + #[derive(Debug, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "gen_rpc.ts")] @@ -229,15 +257,8 @@ pub struct CmdGetHttpResponseEventsReq { #[serde(rename_all = "camelCase")] #[ts(export, export_to = "gen_rpc.ts")] pub struct CmdImportDataReq { - pub file_path: String, - pub destination: ImportDestination, -} - -#[derive(Debug, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "gen_rpc.ts")] -pub struct CmdImportUrlReq { - pub url: String, + pub file_paths: Vec, + pub urls: Vec, pub destination: ImportDestination, } @@ -265,6 +286,16 @@ pub struct CmdImportSourcesForOriginReq { pub url: Option, } +#[derive(Debug, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct CmdDetectImportSourceReq { + #[ts(optional)] + pub file_path: Option, + #[ts(optional)] + pub url: Option, +} + #[derive(Debug, Deserialize, TS)] #[ts(export, export_to = "gen_rpc.ts")] pub struct CmdHttpRequestActionsReq {} @@ -367,11 +398,14 @@ pub struct CmdCurlToRequestReq { pub workspace_id: String, } +#[derive(Debug, Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct CmdCreateExampleWorkspaceReq {} + #[derive(Debug, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "gen_rpc.ts")] pub struct CmdExportDataReq { - pub export_path: String, pub workspace_ids: Vec, pub include_private_environments: bool, } @@ -851,6 +885,8 @@ pub struct CmdSyncApplyReq { pub sync_ops: Vec, pub sync_dir: PathBuf, pub workspace_id: String, + #[serde(default)] + pub fs_only: bool, } #[derive(Debug, Deserialize, TS)] @@ -943,6 +979,7 @@ macro_rules! with_commands { ($callback:ident) => { $callback! { cmd_metadata(CmdMetadataReq) -> AppMetaData, + cmd_agent_setup_status(CmdAgentSetupStatusReq) -> AgentSetupStatus, cmd_template_tokens_to_string(CmdTemplateTokensToStringReq) -> String, cmd_render_template(CmdRenderTemplateReq) -> String, cmd_send_feedback(CmdSendFeedbackReq) -> (), @@ -959,10 +996,10 @@ macro_rules! with_commands { cmd_get_sse_events(CmdGetSseEventsReq) -> Vec, cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec, cmd_import_data(CmdImportDataReq) -> ImportPlan, - cmd_import_url(CmdImportUrlReq) -> ImportPlan, cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult, cmd_list_import_sources(CmdListImportSourcesReq) -> Vec, cmd_import_sources_for_origin(CmdImportSourcesForOriginReq) -> Vec, + cmd_detect_import_source(CmdDetectImportSourceReq) -> String, cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec, cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec, cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (), @@ -979,7 +1016,8 @@ macro_rules! with_commands { cmd_call_grpc_request_action(CmdCallGrpcRequestActionReq) -> (), cmd_call_http_authentication_action(CmdCallHttpAuthenticationActionReq) -> (), cmd_curl_to_request(CmdCurlToRequestReq) -> HttpRequest, - cmd_export_data(CmdExportDataReq) -> (), + cmd_export_data(CmdExportDataReq) -> String, + cmd_create_example_workspace(CmdCreateExampleWorkspaceReq) -> BatchUpsertResult, cmd_save_base64_to_binary(CmdSaveBase64ToBinaryReq) -> (), cmd_save_response(CmdSaveResponseReq) -> (), cmd_send_http_request(CmdSendHttpRequestReq) -> HttpResponse, @@ -1043,7 +1081,7 @@ macro_rules! with_commands { cmd_git_rm_remote(CmdGitRmRemoteReq) -> (), cmd_sync_calculate(CmdSyncCalculateReq) -> Vec, cmd_sync_calculate_fs(CmdSyncCalculateFsReq) -> Vec, - cmd_sync_apply(CmdSyncApplyReq) -> (), + cmd_sync_apply(CmdSyncApplyReq) -> bool, cmd_ws_delete_connections(CmdWsDeleteConnectionsReq) -> (), cmd_ws_send(CmdWsSendReq) -> WebsocketConnection, cmd_ws_close(CmdWsCloseReq) -> WebsocketConnection, diff --git a/crates/common/yaak-rpc/src/lib.rs b/crates/common/yaak-rpc/src/lib.rs index 760e9f21b..30fee70d2 100644 --- a/crates/common/yaak-rpc/src/lib.rs +++ b/crates/common/yaak-rpc/src/lib.rs @@ -13,8 +13,9 @@ pub type BoxFuture = Pin + Send + 'static>>; /// `dispatch` call frame, so it cannot borrow, and contexts are cheap clones /// (handles and `Arc`s). Synchronous handlers wrap into this via `rpc_handler!` /// with no visible change. -type HandlerFn = - Box BoxFuture> + Send + Sync>; +type HandlerFn = Box< + dyn Fn(Ctx, serde_json::Value) -> BoxFuture> + Send + Sync, +>; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RpcError { @@ -138,6 +139,9 @@ impl RpcEventEmitter { /// - `RpcSchema` — a struct with ts-rs derives for TypeScript type generation /// - `RpcEventSchema` — (if events declared) a struct mapping event names to payload types /// +/// Commands under the optional `async_commands` block are `async fn(Ctx, Req)` handlers, +/// wrapped with `rpc_handler_async!`. +/// /// The wire name for each command/event is derived from `stringify!($ident)`. /// /// # Example @@ -161,6 +165,9 @@ macro_rules! define_rpc { commands { $( $handler:ident ( $req:ty ) -> $res:ty ),* $(,)? } + $( async_commands { + $( $async_handler:ident ( $async_req:ty ) -> $async_res:ty ),* $(,)? + } )? events { $( $evt_ident:ident ( $evt_payload:ty ) ),* $(,)? } @@ -168,6 +175,7 @@ macro_rules! define_rpc { pub fn build_router() -> $crate::RpcRouter<$ctx> { let mut router = $crate::RpcRouter::new(); $( router.register(stringify!($handler), $crate::rpc_handler!($handler)); )* + $( $( router.register(stringify!($async_handler), $crate::rpc_handler_async!($async_handler)); )* )? router } @@ -175,6 +183,7 @@ macro_rules! define_rpc { #[ts(export, export_to = "gen_rpc.ts")] pub struct RpcSchema { $( pub $handler: ($req, $res), )* + $( $( pub $async_handler: ($async_req, $async_res), )* )? } #[derive(ts_rs::TS)] @@ -190,10 +199,14 @@ macro_rules! define_rpc { commands { $( $handler:ident ( $req:ty ) -> $res:ty ),* $(,)? } + $( async_commands { + $( $async_handler:ident ( $async_req:ty ) -> $async_res:ty ),* $(,)? + } )? ) => { pub fn build_router() -> $crate::RpcRouter<$ctx> { let mut router = $crate::RpcRouter::new(); $( router.register(stringify!($handler), $crate::rpc_handler!($handler)); )* + $( $( router.register(stringify!($async_handler), $crate::rpc_handler_async!($async_handler)); )* )? router } @@ -201,6 +214,7 @@ macro_rules! define_rpc { #[ts(export, export_to = "gen_rpc.ts")] pub struct RpcSchema { $( pub $handler: ($req, $res), )* + $( $( pub $async_handler: ($async_req, $async_res), )* )? } }; } @@ -249,9 +263,8 @@ macro_rules! rpc_handler_async { Box::new(|ctx, payload| { Box::pin(async move { let req = serde_json::from_value(payload).map_err($crate::RpcError::from)?; - let res = $f(ctx, req) - .await - .map_err(|e| $crate::RpcError { message: e.to_string() })?; + let res = + $f(ctx, req).await.map_err(|e| $crate::RpcError { message: e.to_string() })?; serde_json::to_value(res).map_err($crate::RpcError::from) }) }) diff --git a/crates/yaak-commands/src/agents.rs b/crates/yaak-commands/src/agents.rs new file mode 100644 index 000000000..5a3642bf1 --- /dev/null +++ b/crates/yaak-commands/src/agents.rs @@ -0,0 +1,317 @@ +//! Which of the Agents pane's setup steps are already done on this machine. +//! +//! Detection reads the files the agents keep their MCP servers in and the +//! skills directory `yaak agent install` writes to. A missing or unparsable +//! file is "not detected", never an error: the pane shows a done mark when it +//! can, and the command when it cannot. + +use serde_json::Value; +use std::path::{Path, PathBuf}; +use yaak_rpc_schema::{AgentClient, AgentSetupStatus}; + +const SKILL_NAME: &str = "use-yaak"; +const SERVER_NAME: &str = "yaak"; + +pub fn agent_setup_status(home: &Path, agent: AgentClient) -> AgentSetupStatus { + AgentSetupStatus { + mcp_server: mcp_server_added(home, agent), + skill: skill_installed(home, agent), + } +} + +fn skill_installed(home: &Path, agent: AgentClient) -> Option { + let skills_dir = match agent { + AgentClient::ClaudeCode => home.join(".claude").join("skills"), + AgentClient::Codex => home.join(".codex").join("skills"), + AgentClient::Cursor => home.join(".cursor").join("skills"), + AgentClient::Vscode | AgentClient::Other => home.join(".agents").join("skills"), + }; + Some(skills_dir.join(SKILL_NAME).is_dir()) +} + +fn mcp_server_added(home: &Path, agent: AgentClient) -> Option { + match agent { + AgentClient::ClaudeCode => Some(claude_code_has_server(&home.join(".claude.json"))), + AgentClient::Codex => Some(codex_has_server(&home.join(".codex").join("config.toml"))), + AgentClient::Cursor => { + Some(json_has_server(&home.join(".cursor").join("mcp.json"), "mcpServers")) + } + AgentClient::Vscode => Some(json_has_server(&vscode_user_mcp_json(home), "servers")), + AgentClient::Other => None, + } +} + +fn vscode_user_mcp_json(home: &Path) -> PathBuf { + let user_dir = if cfg!(target_os = "macos") { + home.join("Library").join("Application Support").join("Code").join("User") + } else if cfg!(target_os = "windows") { + std::env::var_os("APPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| home.join("AppData").join("Roaming")) + .join("Code") + .join("User") + } else { + home.join(".config").join("Code").join("User") + }; + user_dir.join("mcp.json") +} + +fn read_json(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&text).ok() +} + +/// Only the CLI's own entry counts. An entry named `yaak` that points at a +/// URL is the deprecated plugin's server and still needs replacing. +fn has_server(servers: Option<&Value>) -> bool { + servers.and_then(|s| s.get(SERVER_NAME)).is_some_and(is_cli_server) +} + +fn is_cli_server(entry: &Value) -> bool { + let command = entry.get("command").and_then(Value::as_str); + let args: Vec<&str> = entry + .get("args") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + is_cli_launch(command, &args) +} + +/// `yaak mcp`, or `npx -y @yaakapp/cli mcp`, which runs the same binary. +fn is_cli_launch(command: Option<&str>, args: &[&str]) -> bool { + let has_mcp = args.contains(&"mcp"); + let via_npx = command_stem(command) == Some("npx") + && args.iter().any(|a| a.trim_start_matches("@yaakapp/cli").len() < a.len()); + has_mcp && (command_stem(command) == Some("yaak") || via_npx) +} + +fn command_stem(command: Option<&str>) -> Option<&str> { + command.and_then(|c| Path::new(c).file_stem().and_then(|s| s.to_str())) +} + +fn json_has_server(path: &Path, key: &str) -> bool { + read_json(path).is_some_and(|root| has_server(root.get(key))) +} + +/// `claude mcp add` writes to the top level with `-s user` and under the +/// current project's entry without it, so both count. +fn claude_code_has_server(path: &Path) -> bool { + let Some(root) = read_json(path) else { + return false; + }; + if has_server(root.get("mcpServers")) { + return true; + } + root.get("projects") + .and_then(|p| p.as_object()) + .is_some_and(|projects| projects.values().any(|p| has_server(p.get("mcpServers")))) +} + +/// A `[mcp_servers.yaak]` table, key bare or quoted, whose `command` is yaak +/// and whose `args` include mcp. The file is scanned line by line, not parsed. +fn codex_has_server(path: &Path) -> bool { + let Ok(text) = std::fs::read_to_string(path) else { + return false; + }; + let mut in_table = false; + let mut command = None; + let mut args = Vec::new(); + let mut in_args = false; + for line in text.lines().map(str::trim) { + if line.starts_with('[') { + in_table = is_codex_server_header(line); + in_args = false; + } else if in_table { + let rest = if in_args { + Some(line) + } else if let Some((key, value)) = line.split_once('=') { + match key.trim() { + "command" => { + command = Some(value.trim().trim_matches(QUOTES).to_string()); + None + } + "args" => Some(value), + _other => None, + } + } else { + None + }; + if let Some(rest) = rest { + args.extend( + rest.split(',') + .map(|a| a.trim().trim_matches(QUOTES_OR_BRACKETS)) + .filter(|a| !a.is_empty()) + .map(str::to_string), + ); + in_args = !rest.contains(']'); + } + } + } + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + is_cli_launch(command.as_deref(), &args) +} + +const QUOTES: [char; 2] = ['"', '\'']; +const QUOTES_OR_BRACKETS: [char; 4] = ['"', '\'', '[', ']']; + +fn is_codex_server_header(line: &str) -> bool { + let Some(rest) = line.strip_prefix('[') else { + return false; + }; + let Some(inner) = rest.split(']').next() else { + return false; + }; + let mut parts = inner.split('.').map(|p| p.trim().trim_matches(|c| c == '"' || c == '\'')); + parts.next() == Some("mcp_servers") && parts.next() == Some(SERVER_NAME) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn write(path: &Path, contents: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); + } + + #[test] + fn nothing_installed() { + let home = tempfile::tempdir().unwrap(); + for agent in [ + AgentClient::ClaudeCode, + AgentClient::Codex, + AgentClient::Cursor, + AgentClient::Vscode, + ] { + let status = agent_setup_status(home.path(), agent); + assert_eq!(status.mcp_server, Some(false), "{agent:?}"); + assert_eq!(status.skill, Some(false), "{agent:?}"); + } + let other = agent_setup_status(home.path(), AgentClient::Other); + assert_eq!(other.mcp_server, None); + assert_eq!(other.skill, Some(false)); + } + + #[test] + fn skill_directories_per_agent() { + let home = tempfile::tempdir().unwrap(); + fs::create_dir_all(home.path().join(".claude/skills/use-yaak")).unwrap(); + fs::create_dir_all(home.path().join(".agents/skills/use-yaak")).unwrap(); + + assert_eq!(agent_setup_status(home.path(), AgentClient::ClaudeCode).skill, Some(true)); + assert_eq!(agent_setup_status(home.path(), AgentClient::Codex).skill, Some(false)); + assert_eq!(agent_setup_status(home.path(), AgentClient::Cursor).skill, Some(false)); + assert_eq!(agent_setup_status(home.path(), AgentClient::Vscode).skill, Some(true)); + assert_eq!(agent_setup_status(home.path(), AgentClient::Other).skill, Some(true)); + } + + #[test] + fn claude_code_top_level_or_project_scope() { + let home = tempfile::tempdir().unwrap(); + let path = home.path().join(".claude.json"); + + write(&path, r#"{"mcpServers":{"yaak":{"command":"yaak","args":["mcp"]}}}"#); + assert_eq!(agent_setup_status(home.path(), AgentClient::ClaudeCode).mcp_server, Some(true)); + + write( + &path, + r#"{"projects":{"/Users/me/app":{"mcpServers":{"yaak":{"command":"/usr/local/bin/yaak","args":["mcp"]}}}}}"#, + ); + assert_eq!(agent_setup_status(home.path(), AgentClient::ClaudeCode).mcp_server, Some(true)); + + write( + &path, + r#"{"mcpServers":{"yaak":{"type":"http","url":"http://127.0.0.1:64343/mcp"}}}"#, + ); + assert_eq!( + agent_setup_status(home.path(), AgentClient::ClaudeCode).mcp_server, + Some(false) + ); + + write(&path, r#"{"mcpServers":{"other":{}},"projects":{"/x":{"mcpServers":{}}}}"#); + assert_eq!( + agent_setup_status(home.path(), AgentClient::ClaudeCode).mcp_server, + Some(false) + ); + + write(&path, "{ not json"); + assert_eq!( + agent_setup_status(home.path(), AgentClient::ClaudeCode).mcp_server, + Some(false) + ); + } + + #[test] + fn codex_table_header() { + let home = tempfile::tempdir().unwrap(); + let path = home.path().join(".codex/config.toml"); + + write( + &path, + "model = \"o3\"\n\n[mcp_servers.yaak]\ncommand = \"yaak\"\nargs = [\"mcp\"]\n", + ); + assert_eq!(agent_setup_status(home.path(), AgentClient::Codex).mcp_server, Some(true)); + + write(&path, " [ mcp_servers . \"yaak\" ]\ncommand = 'yaak'\nargs = ['mcp']\n[other]\n"); + assert_eq!(agent_setup_status(home.path(), AgentClient::Codex).mcp_server, Some(true)); + + write(&path, "[mcp_servers.yaak]\nurl = \"http://127.0.0.1:64343/mcp\"\n"); + assert_eq!(agent_setup_status(home.path(), AgentClient::Codex).mcp_server, Some(false)); + + write(&path, "[mcp_servers.yaak]\ncommand = \"yaak\"\nargs = [\n \"mcp\",\n]\n"); + assert_eq!(agent_setup_status(home.path(), AgentClient::Codex).mcp_server, Some(true)); + + write(&path, "[mcp_servers.yaak.env]\nFOO = \"bar\"\n"); + assert_eq!(agent_setup_status(home.path(), AgentClient::Codex).mcp_server, Some(false)); + + write( + &path, + "[mcp_servers.yaak]\n[mcp_servers.other]\ncommand = \"yaak\"\nargs = [\"mcp\"]\n", + ); + assert_eq!(agent_setup_status(home.path(), AgentClient::Codex).mcp_server, Some(false)); + + write(&path, "[mcp_servers.yaakish]\ncommand = \"x\"\n[mcp_servers]\n"); + assert_eq!(agent_setup_status(home.path(), AgentClient::Codex).mcp_server, Some(false)); + } + + #[test] + fn cursor_and_vscode_json() { + let home = tempfile::tempdir().unwrap(); + + write( + &home.path().join(".cursor/mcp.json"), + r#"{"mcpServers":{"yaak":{"command":"yaak","args":["mcp"]}}}"#, + ); + assert_eq!(agent_setup_status(home.path(), AgentClient::Cursor).mcp_server, Some(true)); + + write( + &home.path().join(".cursor/mcp.json"), + r#"{"mcpServers":{"yaak":{"url":"http://127.0.0.1:64343/mcp"}}}"#, + ); + assert_eq!(agent_setup_status(home.path(), AgentClient::Cursor).mcp_server, Some(false)); + + write( + &home.path().join(".cursor/mcp.json"), + r#"{"mcpServers":{"yaak":{"command":"npx","args":["-y","@yaakapp/cli","mcp"]}}}"#, + ); + assert_eq!(agent_setup_status(home.path(), AgentClient::Cursor).mcp_server, Some(true)); + + write( + &home.path().join(".cursor/mcp.json"), + r#"{"mcpServers":{"yaak":{"command":"npx","args":["-y","mcp-remote","http://127.0.0.1:64343/mcp"]}}}"#, + ); + assert_eq!(agent_setup_status(home.path(), AgentClient::Cursor).mcp_server, Some(false)); + + write(&home.path().join(".cursor/mcp.json"), r#"{"servers":{"yaak":{}}}"#); + assert_eq!(agent_setup_status(home.path(), AgentClient::Cursor).mcp_server, Some(false)); + + if !cfg!(target_os = "windows") { + write( + &vscode_user_mcp_json(home.path()), + r#"{"servers":{"yaak":{"type":"stdio","command":"yaak","args":["mcp"]}}}"#, + ); + assert_eq!(agent_setup_status(home.path(), AgentClient::Vscode).mcp_server, Some(true)); + } + } +} diff --git a/crates/yaak-commands/src/data.rs b/crates/yaak-commands/src/data.rs index 0069506fa..ac38c9cfd 100644 --- a/crates/yaak-commands/src/data.rs +++ b/crates/yaak-commands/src/data.rs @@ -2,22 +2,29 @@ use crate::error::Result; use crate::host::Host; -use std::path::Path; -use yaak::export::{self, ExportDataParams}; +use yaak_models::example::create_example_workspace; +use yaak_models::export::{self, ExportDataParams}; +use yaak_models::util::BatchUpsertResult; use yaak_rpc_schema::*; use yaak_templates::format_json::format_json; -pub async fn cmd_export_data(host: H, req: CmdExportDataReq) -> Result<()> { +pub async fn cmd_export_data(host: H, req: CmdExportDataReq) -> Result { let version = host.app_version(); Ok(export::export_data(ExportDataParams { query_manager: host.query_manager(), yaak_version: &version, - export_path: Path::new(&req.export_path), workspace_ids: req.workspace_ids.iter().map(|s| s.as_str()).collect(), include_private_environments: req.include_private_environments, })?) } +pub async fn cmd_create_example_workspace( + host: H, + _req: CmdCreateExampleWorkspaceReq, +) -> Result { + Ok(create_example_workspace(host.query_manager(), &host.update_source())?) +} + pub async fn cmd_format_json(_host: H, req: CmdFormatJsonReq) -> Result { Ok(format_json(&req.text, " ")) } diff --git a/crates/yaak-commands/src/host.rs b/crates/yaak-commands/src/host.rs index 5d8a54b10..b5ff79f46 100644 --- a/crates/yaak-commands/src/host.rs +++ b/crates/yaak-commands/src/host.rs @@ -72,7 +72,7 @@ pub trait Host: Clone { self.query_manager().connect() } - fn blobs(&self) -> BlobContext { + fn blobs(&self) -> BlobContext<'_> { self.blob_manager().connect() } } diff --git a/crates/yaak-commands/src/lib.rs b/crates/yaak-commands/src/lib.rs index 72563c207..7be82edd6 100644 --- a/crates/yaak-commands/src/lib.rs +++ b/crates/yaak-commands/src/lib.rs @@ -12,6 +12,7 @@ //! can serve (native windows, the updater, dialogs) or that still lean on it. pub mod actions; +pub mod agents; pub mod auth; pub mod data; pub mod encryption; diff --git a/crates/yaak-commands/src/models.rs b/crates/yaak-commands/src/models.rs index 85784f95c..6a4f04743 100644 --- a/crates/yaak-commands/src/models.rs +++ b/crates/yaak-commands/src/models.rs @@ -12,10 +12,11 @@ use yaak_models::queries::workspaces::default_headers; use yaak_rpc_schema::*; pub async fn models_upsert(host: H, req: ModelsUpsertReq) -> Result { - let db = host.db(); let blobs = host.blob_manager(); let source = host.update_source(); - Ok(yaak_models::models_ops::upsert_model(&db, blobs, req.model, &source)?) + Ok(host + .query_manager() + .with_tx(|tx| yaak_models::models_ops::upsert_model(tx, blobs, req.model, &source))?) } /// Deletes cascade — a workspace can hold thousands of requests — and run in a @@ -56,7 +57,9 @@ pub async fn models_snapshot_request( host: H, req: ModelsSnapshotRequestReq, ) -> Result { - Ok(host.db().snapshot_request_by_id(&req.request_id, req.reason)?) + Ok(host + .query_manager() + .with_tx(|tx| tx.snapshot_request_by_id(&req.request_id, req.reason))?) } /// A version and the live request side by side, for the diff and for deciding @@ -78,7 +81,9 @@ pub async fn models_restore_request_version( req: ModelsRestoreRequestVersionReq, ) -> Result { let source = host.update_source(); - let restored = host.db().restore_request_version(&req.version_id, &source)?; + let restored = host + .query_manager() + .with_tx(|tx| tx.restore_request_version(&req.version_id, &source))?; Ok(restored.id().to_string()) } @@ -112,12 +117,9 @@ pub async fn models_upsert_graphql_introspection( req: ModelsUpsertGraphqlIntrospectionReq, ) -> Result { let source = host.update_source(); - Ok(host.db().upsert_graphql_introspection( - &req.workspace_id, - &req.request_id, - req.content, - &source, - )?) + Ok(host.query_manager().with_tx(|tx| { + tx.upsert_graphql_introspection(&req.workspace_id, &req.request_id, req.content, &source) + })?) } /// Everything the frontend's model store needs to boot, as one JSON string. @@ -149,9 +151,16 @@ pub async fn models_workspace_models( // Add the workspace children if let Some(wid) = req.workspace_id.as_deref() { + // Opening a workspace is where the rows it is assumed to have get created + host.query_manager().with_tx(|tx| { + tx.ensure_base_environment(wid)?; + tx.ensure_default_cookie_jar(wid)?; + tx.ensure_workspace_meta(wid)?; + Ok::<(), yaak_models::error::Error>(()) + })?; let db = host.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_environments(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()); @@ -169,23 +178,26 @@ pub async fn cmd_get_workspace_meta( host: H, req: CmdGetWorkspaceMetaReq, ) -> Result { - let db = host.db(); - let workspace = db.get_workspace(&req.workspace_id)?; - Ok(db.get_or_create_workspace_meta(&workspace.id)?) + let workspace = host.db().get_workspace(&req.workspace_id)?; + Ok(host.query_manager().with_tx(|tx| tx.ensure_workspace_meta(&workspace.id))?) } pub async fn cmd_delete_all_grpc_connections( host: H, req: CmdDeleteAllGrpcConnectionsReq, ) -> Result<()> { - Ok(host.db().delete_all_grpc_connections_for_request(&req.request_id, &host.update_source())?) + Ok(host.query_manager().with_tx(|tx| { + tx.delete_all_grpc_connections_for_request(&req.request_id, &host.update_source()) + })?) } pub async fn cmd_delete_all_http_responses( host: H, req: CmdDeleteAllHttpResponsesReq, ) -> Result<()> { - host.db().delete_all_http_responses_for_request(&req.request_id, &host.update_source())?; + host.query_manager().with_tx(|tx| { + tx.delete_all_http_responses_for_request(&req.request_id, &host.update_source()) + })?; Ok(()) } @@ -193,9 +205,9 @@ pub async fn cmd_ws_delete_connections( host: H, req: CmdWsDeleteConnectionsReq, ) -> Result<()> { - Ok(host - .db() - .delete_all_websocket_connections_for_request(&req.request_id, &host.update_source())?) + Ok(host.query_manager().with_tx(|tx| { + tx.delete_all_websocket_connections_for_request(&req.request_id, &host.update_source()) + })?) } pub async fn cmd_delete_send_history(host: H, req: CmdDeleteSendHistoryReq) -> Result<()> { diff --git a/crates/yaak-commands/src/plugins.rs b/crates/yaak-commands/src/plugins.rs index 0971f930a..cbf1c4ee5 100644 --- a/crates/yaak-commands/src/plugins.rs +++ b/crates/yaak-commands/src/plugins.rs @@ -37,6 +37,7 @@ fn fallback_plugin_metadata(directory: &str) -> PluginMetadata { description: Some(format!("Plugin metadata could not be loaded from {directory}")), homepage_url: None, repository_url: None, + deprecated: None, } } diff --git a/crates/yaak-commands/tests/test_host.rs b/crates/yaak-commands/tests/test_host.rs index 15440c8c7..de31d9f27 100644 --- a/crates/yaak-commands/tests/test_host.rs +++ b/crates/yaak-commands/tests/test_host.rs @@ -118,6 +118,7 @@ impl Host for TestHost { #[tokio::test(flavor = "multi_thread")] async fn writes_carry_the_client_id() { let host = TestHost::new(); + host.drain_writes(); // the rows startup creates let workspace = Workspace { name: "From a test".to_string(), ..Default::default() }; let id = models_upsert(host.clone(), ModelsUpsertReq { model: AnyModel::Workspace(workspace) }) @@ -406,21 +407,23 @@ async fn a_single_threaded_host_can_implement_the_trait() { // shared code; only the callback came from the host. Rendering a real // variable is what proves the chain was resolved rather than skipped. let environment = host - .db() - .upsert_environment( - &Environment { - workspace_id: id.clone(), - name: "Test env".to_string(), - variables: vec![EnvironmentVariable { - enabled: true, - name: "greeting".to_string(), - value: "hello".to_string(), - id: None, - }], - ..Default::default() - }, - &host.update_source(), - ) + .query_manager() + .with_tx(|tx| { + tx.upsert_environment( + &Environment { + workspace_id: id.clone(), + name: "Test env".to_string(), + variables: vec![EnvironmentVariable { + enabled: true, + name: "greeting".to_string(), + value: "hello".to_string(), + id: None, + }], + ..Default::default() + }, + &host.update_source(), + ) + }) .expect("seed environment"); let rendered = cmd_render_template( @@ -459,30 +462,33 @@ async fn auth_values_are_rendered_before_the_host_sees_them() { }; let workspace = host - .db() - .upsert_workspace( - &Workspace { name: "Auth".to_string(), ..Default::default() }, - &host.update_source(), - ) + .query_manager() + .with_tx(|tx| { + tx.upsert_workspace( + &Workspace { name: "Auth".to_string(), ..Default::default() }, + &host.update_source(), + ) + }) .expect("workspace"); - host.db() - .upsert_environment( - &Environment { - workspace_id: workspace.id.clone(), - name: "Env".to_string(), - variables: vec![EnvironmentVariable { - enabled: true, - name: "token".to_string(), - value: "s3cret".to_string(), - id: None, - }], - ..Default::default() - }, - &host.update_source(), - ) + host.query_manager() + .with_tx(|tx| { + tx.upsert_environment( + &Environment { + workspace_id: workspace.id.clone(), + name: "Env".to_string(), + variables: vec![EnvironmentVariable { + enabled: true, + name: "token".to_string(), + value: "s3cret".to_string(), + id: None, + }], + ..Default::default() + }, + &host.update_source(), + ) + }) .expect("environment"); - let environment = - host.db().list_environments_ensure_base(&workspace.id).expect("list").remove(0); + let environment = host.db().list_environments(&workspace.id).expect("list").remove(0); let mut values = HashMap::new(); values.insert("password".to_string(), JsonPrimitive::String("${[ token ]}".to_string())); @@ -521,30 +527,33 @@ async fn template_function_values_are_rendered_before_the_host_sees_them() { }; let workspace = host - .db() - .upsert_workspace( - &Workspace { name: "Functions".to_string(), ..Default::default() }, - &host.update_source(), - ) + .query_manager() + .with_tx(|tx| { + tx.upsert_workspace( + &Workspace { name: "Functions".to_string(), ..Default::default() }, + &host.update_source(), + ) + }) .expect("workspace"); - host.db() - .upsert_environment( - &Environment { - workspace_id: workspace.id.clone(), - name: "Env".to_string(), - variables: vec![EnvironmentVariable { - enabled: true, - name: "1PASSWORD_TOKEN".to_string(), - value: "ops_abc123".to_string(), - id: None, - }], - ..Default::default() - }, - &host.update_source(), - ) + host.query_manager() + .with_tx(|tx| { + tx.upsert_environment( + &Environment { + workspace_id: workspace.id.clone(), + name: "Env".to_string(), + variables: vec![EnvironmentVariable { + enabled: true, + name: "1PASSWORD_TOKEN".to_string(), + value: "ops_abc123".to_string(), + id: None, + }], + ..Default::default() + }, + &host.update_source(), + ) + }) .expect("environment"); - let environment = - host.db().list_environments_ensure_base(&workspace.id).expect("list").remove(0); + let environment = host.db().list_environments(&workspace.id).expect("list").remove(0); let mut values = HashMap::new(); values.insert("token".to_string(), JsonPrimitive::String("${[1PASSWORD_TOKEN]}".to_string())); diff --git a/crates/yaak-common/Cargo.toml b/crates/yaak-common/Cargo.toml index 42bdf949e..168b9f2d3 100644 --- a/crates/yaak-common/Cargo.toml +++ b/crates/yaak-common/Cargo.toml @@ -5,5 +5,6 @@ edition = "2024" publish = false [dependencies] +dirs = "7" serde_json = { workspace = true } tokio = { workspace = true, features = ["process"] } diff --git a/crates/yaak-common/src/data_dir.rs b/crates/yaak-common/src/data_dir.rs new file mode 100644 index 000000000..b77521e87 --- /dev/null +++ b/crates/yaak-common/src/data_dir.rs @@ -0,0 +1,46 @@ +use std::path::PathBuf; + +/// The data directory of the app with identifier `app_id`, as Tauri resolves it. Under WSL, +/// the Windows app's directory when it exists. +pub fn resolve_data_dir(app_id: &str) -> PathBuf { + if let Some(dir) = wsl_data_dir(app_id) { + return dir; + } + dirs::data_dir().expect("Could not determine data directory").join(app_id) +} + +/// Detect WSL and resolve the Windows AppData\Roaming path for the app's data directory. +fn wsl_data_dir(app_id: &str) -> Option { + if !cfg!(target_os = "linux") { + return None; + } + + let proc_version = std::fs::read_to_string("/proc/version").ok()?; + let is_wsl = proc_version.to_lowercase().contains("microsoft"); + if !is_wsl { + return None; + } + + // We're in WSL, so try to resolve the app's data directory in Windows + + // Get the Windows %APPDATA% path via cmd.exe + let appdata_output = + std::process::Command::new("cmd.exe").args(["/C", "echo", "%APPDATA%"]).output().ok()?; + + let win_path = String::from_utf8(appdata_output.stdout).ok()?.trim().to_string(); + if win_path.is_empty() || win_path == "%APPDATA%" { + return None; + } + + // Convert Windows path to WSL path using wslpath (handles custom mount points) + let wslpath_output = std::process::Command::new("wslpath").arg(&win_path).output().ok()?; + + let wsl_appdata = String::from_utf8(wslpath_output.stdout).ok()?.trim().to_string(); + if wsl_appdata.is_empty() { + return None; + } + + let wsl_path = PathBuf::from(wsl_appdata).join(app_id); + + if wsl_path.exists() { Some(wsl_path) } else { None } +} diff --git a/crates/yaak-common/src/lib.rs b/crates/yaak-common/src/lib.rs index 16a3846f6..f755fd4d0 100644 --- a/crates/yaak-common/src/lib.rs +++ b/crates/yaak-common/src/lib.rs @@ -1,3 +1,4 @@ pub mod command; +pub mod data_dir; pub mod platform; pub mod serde; diff --git a/crates/yaak-crypto/Cargo.toml b/crates/yaak-crypto/Cargo.toml index 202a0360c..51a670038 100644 --- a/crates/yaak-crypto/Cargo.toml +++ b/crates/yaak-crypto/Cargo.toml @@ -6,8 +6,8 @@ publish = false [dependencies] base32 = "0.5.1" # For encoding human-readable key -base64 = "0.22.1" # For encoding in the database -chacha20poly1305 = "0.10.1" +base64 = "0.23.1" # For encoding in the database +chacha20poly1305 = "0.11.0" keyring = { workspace = true, features = ["apple-native", "windows-native", "sync-secret-service"] } log = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/crates/yaak-crypto/src/encryption.rs b/crates/yaak-crypto/src/encryption.rs index 99f722099..bf4a2271c 100644 --- a/crates/yaak-crypto/src/encryption.rs +++ b/crates/yaak-crypto/src/encryption.rs @@ -1,14 +1,14 @@ use crate::error::Error::{DecryptionError, EncryptionError, InvalidEncryptedData}; use crate::error::Result; -use chacha20poly1305::aead::generic_array::typenum::Unsigned; -use chacha20poly1305::aead::{Aead, AeadCore, Key, KeyInit, OsRng}; -use chacha20poly1305::XChaCha20Poly1305; +use chacha20poly1305::aead::array::typenum::Unsigned; +use chacha20poly1305::aead::{Aead, AeadCore, Generate, Key, KeyInit}; +use chacha20poly1305::{XChaCha20Poly1305, XNonce}; const ENCRYPTION_TAG: &str = "yA4k3nC"; const ENCRYPTION_VERSION: u8 = 1; pub(crate) fn encrypt_data(data: &[u8], key: &Key) -> Result> { - let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng); + let nonce = XNonce::generate(); let cipher = XChaCha20Poly1305::new(&key); let ciphered_data = cipher.encrypt(&nonce, data).map_err(|_| EncryptionError)?; @@ -34,11 +34,12 @@ pub(crate) fn decrypt_data(cipher_data: &[u8], key: &Key) -> return Err(InvalidEncryptedData); } - let nonce_bytes = ::NonceSize::to_usize(); + let nonce_bytes = ::NonceSize::USIZE; let (nonce, ciphered_data) = rest.split_at_checked(nonce_bytes).ok_or(InvalidEncryptedData)?; + let nonce: &XNonce = nonce.try_into().map_err(|_| InvalidEncryptedData)?; let cipher = XChaCha20Poly1305::new(&key); - cipher.decrypt(nonce.into(), ciphered_data).map_err(|_e| DecryptionError) + cipher.decrypt(nonce, ciphered_data).map_err(|_e| DecryptionError) } #[cfg(test)] @@ -46,12 +47,12 @@ mod test { use crate::encryption::{decrypt_data, encrypt_data}; use crate::error::Error::InvalidEncryptedData; use crate::error::Result; - use chacha20poly1305::aead::OsRng; - use chacha20poly1305::{KeyInit, XChaCha20Poly1305}; + use chacha20poly1305::aead::{Generate, Key}; + use chacha20poly1305::XChaCha20Poly1305; #[test] fn test_encrypt_decrypt() -> Result<()> { - let key = XChaCha20Poly1305::generate_key(OsRng); + let key = Key::::generate(); let encrypted = encrypt_data("hello world".as_bytes(), &key)?; let decrypted = decrypt_data(encrypted.as_slice(), &key)?; assert_eq!(String::from_utf8(decrypted).unwrap(), "hello world"); @@ -60,7 +61,7 @@ mod test { #[test] fn test_decrypt_empty() -> Result<()> { - let key = XChaCha20Poly1305::generate_key(OsRng); + let key = Key::::generate(); let encrypted = encrypt_data(&[], &key)?; assert_eq!(encrypted.len(), 48); let decrypted = decrypt_data(encrypted.as_slice(), &key)?; @@ -70,7 +71,7 @@ mod test { #[test] fn test_decrypt_bad_version() -> Result<()> { - let key = XChaCha20Poly1305::generate_key(OsRng); + let key = Key::::generate(); let mut encrypted = encrypt_data("hello world".as_bytes(), &key)?; encrypted[7] = 0; let decrypted = decrypt_data(encrypted.as_slice(), &key); @@ -80,7 +81,7 @@ mod test { #[test] fn test_decrypt_bad_tag() -> Result<()> { - let key = XChaCha20Poly1305::generate_key(OsRng); + let key = Key::::generate(); let mut encrypted = encrypt_data("hello world".as_bytes(), &key)?; encrypted[0] = 2; let decrypted = decrypt_data(encrypted.as_slice(), &key); @@ -90,9 +91,29 @@ mod test { #[test] fn test_decrypt_unencrypted_data() -> Result<()> { - let key = XChaCha20Poly1305::generate_key(OsRng); + let key = Key::::generate(); let decrypted = decrypt_data("123".as_bytes(), &key); assert!(matches!(decrypted, Err(InvalidEncryptedData))); Ok(()) } } + +#[cfg(test)] +mod compatibility { + use super::*; + + /// Produced by chacha20poly1305 0.10.1. Decrypting it here proves a crate + /// upgrade hasn't changed the on-disk format, which users already have + /// rows of in their databases. + const V1_FROM_0_10: &str = "7941346b336e43017c7cb13467eecaa963b11734be636f9cc4152de348584fb27e95d1a70973e557cd335cf29e12d0d305d63ca0aa168f1b17003b1690a9d49140f0"; + + #[test] + fn decrypts_data_written_by_the_previous_release() { + let bytes: Vec = (0..V1_FROM_0_10.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&V1_FROM_0_10[i..i + 2], 16).unwrap()) + .collect(); + let key = Key::::from([7u8; 32]); + assert_eq!(decrypt_data(&bytes, &key).unwrap(), b"yaak golden vector"); + } +} diff --git a/crates/yaak-crypto/src/manager.rs b/crates/yaak-crypto/src/manager.rs index 3eb63e956..b17111cb0 100644 --- a/crates/yaak-crypto/src/manager.rs +++ b/crates/yaak-crypto/src/manager.rs @@ -83,7 +83,7 @@ impl EncryptionManager { let workspace_meta = self.query_manager.with_tx::(|tx| { let workspace = tx.get_workspace(workspace_id)?; - let workspace_meta = tx.get_or_create_workspace_meta(workspace_id)?; + let workspace_meta = tx.ensure_workspace_meta(workspace_id)?; tx.upsert_workspace( &Workspace { encryption_key_challenge, ..workspace }, &UpdateSource::Background, @@ -103,7 +103,7 @@ impl EncryptionManager { pub fn ensure_workspace_key(&self, workspace_id: &str) -> Result { let workspace_meta = - self.query_manager.connect().get_or_create_workspace_meta(workspace_id)?; + self.query_manager.with_tx(|tx| tx.ensure_workspace_meta(workspace_id))?; // Already exists if let Some(_) = workspace_meta.encryption_key { @@ -120,7 +120,7 @@ impl EncryptionManager { self.query_manager.with_tx::<(), Error>(|tx| { let workspace = tx.get_workspace(workspace_id)?; - let workspace_meta = tx.get_or_create_workspace_meta(workspace_id)?; + let workspace_meta = tx.ensure_workspace_meta(workspace_id)?; // Clear encryption challenge on workspace tx.upsert_workspace( @@ -152,10 +152,12 @@ impl EncryptionManager { } }; - let db = self.query_manager.connect(); - let workspace_meta = db.get_or_create_workspace_meta(workspace_id)?; - - let key = match workspace_meta.encryption_key { + let key = match self + .query_manager + .connect() + .get_workspace_meta(workspace_id) + .and_then(|m| m.encryption_key) + { None => return Err(MissingWorkspaceKey), Some(k) => k, }; diff --git a/crates/yaak-crypto/src/master_key.rs b/crates/yaak-crypto/src/master_key.rs index 8d1eac136..887601d57 100644 --- a/crates/yaak-crypto/src/master_key.rs +++ b/crates/yaak-crypto/src/master_key.rs @@ -2,7 +2,7 @@ use crate::encryption::{decrypt_data, encrypt_data}; use crate::error::Error::GenericError; use crate::error::Result; use base32::Alphabet; -use chacha20poly1305::aead::{Key, KeyInit, OsRng}; +use chacha20poly1305::aead::{Generate, Key}; use chacha20poly1305::XChaCha20Poly1305; use keyring::{Entry, Error}; use log::info; @@ -24,11 +24,12 @@ impl MasterKey { let without_prefix = encoded.strip_prefix(HUMAN_PREFIX).unwrap_or(&encoded); let key_bytes = base32::decode(Alphabet::Crockford {}, &without_prefix) .ok_or(GenericError("Failed to decode master key".to_string()))?; - Key::::clone_from_slice(key_bytes.as_slice()) + Key::::try_from(key_bytes.as_slice()) + .map_err(|_| GenericError("Master key is the wrong length".to_string()))? } Err(Error::NoEntry) => { info!("Creating new master key"); - let key = XChaCha20Poly1305::generate_key(OsRng); + let key = Key::::generate(); let encoded = base32::encode(Alphabet::Crockford {}, key.as_slice()); let with_prefix = format!("{HUMAN_PREFIX}{encoded}"); entry.set_password(&with_prefix)?; @@ -50,9 +51,7 @@ impl MasterKey { #[cfg(test)] pub(crate) fn test_key() -> Self { - let key: Key = Key::::clone_from_slice( - "00000000000000000000000000000000".as_bytes(), - ); + let key = Key::::from(*b"00000000000000000000000000000000"); Self { key } } } diff --git a/crates/yaak-crypto/src/workspace_key.rs b/crates/yaak-crypto/src/workspace_key.rs index f4a909d65..f045d1937 100644 --- a/crates/yaak-crypto/src/workspace_key.rs +++ b/crates/yaak-crypto/src/workspace_key.rs @@ -2,7 +2,7 @@ use crate::encryption::{decrypt_data, encrypt_data}; use crate::error::Error::InvalidHumanKey; use crate::error::Result; use base32::Alphabet; -use chacha20poly1305::aead::{Key, KeyInit, OsRng}; +use chacha20poly1305::aead::{Generate, Key}; use chacha20poly1305::{KeySizeUser, XChaCha20Poly1305}; #[derive(Debug, Clone)] @@ -39,7 +39,9 @@ impl WorkspaceKey { } pub(crate) fn from_raw_key(key: &[u8]) -> Self { - Self { key: Key::::clone_from_slice(key) } + Self { + key: Key::::try_from(key).expect("workspace key must be 32 bytes"), + } } pub(crate) fn raw_key(&self) -> &[u8] { @@ -47,7 +49,7 @@ impl WorkspaceKey { } pub(crate) fn create() -> Result { - let key = XChaCha20Poly1305::generate_key(OsRng); + let key = Key::::generate(); Ok(Self::from_raw_key(key.as_slice())) } diff --git a/crates/yaak-filter/Cargo.toml b/crates/yaak-filter/Cargo.toml new file mode 100644 index 000000000..343e0a13e --- /dev/null +++ b/crates/yaak-filter/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "yaak-filter" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +fancy-regex = "0.19" +indexmap = "2" +sxd-document = "0.3.2" +sxd-xpath = "0.4.2" + +[dev-dependencies] +serde_json = { workspace = true, features = ["preserve_order"] } diff --git a/crates/yaak-filter/src/jsonpath/json.rs b/crates/yaak-filter/src/jsonpath/json.rs new file mode 100644 index 000000000..5f6b0420f --- /dev/null +++ b/crates/yaak-filter/src/jsonpath/json.rs @@ -0,0 +1,423 @@ +//! The response document as `JSON.parse` builds it, and `JSON.stringify(_, null, 2)`. + +use super::value::JsValue; +use indexmap::IndexMap; + +const MAX_DEPTH: usize = 128; + +#[derive(Debug)] +pub enum Json { + Null, + Bool(bool), + Number(f64), + String(String), + Array(Vec), + Object(IndexMap), +} + +pub fn parse(text: &str) -> Result { + let mut parser = Parser { bytes: text.as_bytes(), text, pos: 0 }; + parser.skip_ws(); + let value = parser.value(0)?; + parser.skip_ws(); + if parser.pos < parser.bytes.len() { + return Err(parser.error("trailing characters")); + } + Ok(value) +} + +struct Parser<'t> { + bytes: &'t [u8], + text: &'t str, + pos: usize, +} + +impl Parser<'_> { + fn error(&self, message: &str) -> String { + let mut end = self.pos.min(self.text.len()); + while !self.text.is_char_boundary(end) { + end -= 1; + } + let before = &self.text[..end]; + let line = before.matches('\n').count() + 1; + let column = before.rsplit('\n').next().map_or(0, |l| l.chars().count()) + 1; + format!("{message} at line {line} column {column}") + } + + fn peek(&self) -> Option { + self.bytes.get(self.pos).copied() + } + + fn skip_ws(&mut self) { + while let Some(b' ' | b'\t' | b'\n' | b'\r') = self.peek() { + self.pos += 1; + } + } + + fn expect_word(&mut self, word: &str, value: Json) -> Result { + if self.text[self.pos..].starts_with(word) { + self.pos += word.len(); + Ok(value) + } else { + Err(self.error("expected value")) + } + } + + fn value(&mut self, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(self.error("recursion limit exceeded")); + } + match self.peek() { + None => Err(self.error("EOF while parsing a value")), + Some(b'n') => self.expect_word("null", Json::Null), + Some(b't') => self.expect_word("true", Json::Bool(true)), + Some(b'f') => self.expect_word("false", Json::Bool(false)), + Some(b'"') => Ok(Json::String(self.string()?)), + Some(b'[') => self.array(depth), + Some(b'{') => self.object(depth), + Some(b'-' | b'0'..=b'9') => self.number(), + Some(_) => Err(self.error("expected value")), + } + } + + fn array(&mut self, depth: usize) -> Result { + self.pos += 1; + let mut items = Vec::new(); + self.skip_ws(); + if self.peek() == Some(b']') { + self.pos += 1; + return Ok(Json::Array(items)); + } + loop { + self.skip_ws(); + items.push(self.value(depth + 1)?); + self.skip_ws(); + match self.peek() { + Some(b',') => self.pos += 1, + Some(b']') => { + self.pos += 1; + return Ok(Json::Array(items)); + } + _ => return Err(self.error("expected `,` or `]`")), + } + } + } + + fn object(&mut self, depth: usize) -> Result { + self.pos += 1; + let mut map = IndexMap::new(); + self.skip_ws(); + if self.peek() == Some(b'}') { + self.pos += 1; + return Ok(Json::Object(map)); + } + loop { + self.skip_ws(); + if self.peek() != Some(b'"') { + return Err(self.error("key must be a string")); + } + let key = self.string()?; + self.skip_ws(); + if self.peek() != Some(b':') { + return Err(self.error("expected `:`")); + } + self.pos += 1; + self.skip_ws(); + let value = self.value(depth + 1)?; + map.insert(key, value); + self.skip_ws(); + match self.peek() { + Some(b',') => self.pos += 1, + Some(b'}') => { + self.pos += 1; + return Ok(Json::Object(js_key_order(map))); + } + _ => return Err(self.error("expected `,` or `}`")), + } + } + } + + fn number(&mut self) -> Result { + let start = self.pos; + if self.peek() == Some(b'-') { + self.pos += 1; + } + match self.peek() { + Some(b'0') => self.pos += 1, + Some(b'1'..=b'9') => self.digits(), + _ => return Err(self.error("invalid number")), + } + if self.peek() == Some(b'.') { + self.pos += 1; + if !matches!(self.peek(), Some(b'0'..=b'9')) { + return Err(self.error("invalid number")); + } + self.digits(); + } + if let Some(b'e' | b'E') = self.peek() { + self.pos += 1; + if let Some(b'+' | b'-') = self.peek() { + self.pos += 1; + } + if !matches!(self.peek(), Some(b'0'..=b'9')) { + return Err(self.error("invalid number")); + } + self.digits(); + } + let number = + self.text[start..self.pos].parse::().map_err(|e| self.error(&e.to_string()))?; + Ok(Json::Number(number)) + } + + fn digits(&mut self) { + while let Some(b'0'..=b'9') = self.peek() { + self.pos += 1; + } + } + + fn hex4(&mut self) -> Result { + let hex = + self.text.get(self.pos..self.pos + 4).ok_or_else(|| self.error("invalid escape"))?; + let unit = u16::from_str_radix(hex, 16).map_err(|_| self.error("invalid escape"))?; + if !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(self.error("invalid escape")); + } + self.pos += 4; + Ok(unit) + } + + fn string(&mut self) -> Result { + self.pos += 1; + let mut out = String::new(); + loop { + let run = self.pos; + while let Some(b) = self.peek() { + if b == b'"' || b == b'\\' || b < 0x20 { + break; + } + self.pos += 1; + } + out.push_str(&self.text[run..self.pos]); + match self.peek() { + None => return Err(self.error("EOF while parsing a string")), + Some(b'"') => { + self.pos += 1; + return Ok(out); + } + Some(b'\\') => { + self.pos += 1; + let escape = + self.peek().ok_or_else(|| self.error("EOF while parsing a string"))?; + self.pos += 1; + match escape { + b'"' => out.push('"'), + b'\\' => out.push('\\'), + b'/' => out.push('/'), + b'b' => out.push('\u{8}'), + b'f' => out.push('\u{c}'), + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b't' => out.push('\t'), + b'u' => { + let unit = self.hex4()?; + let mut units = vec![unit]; + if (0xD800..0xDC00).contains(&unit) + && self.text[self.pos..].starts_with("\\u") + { + let save = self.pos; + self.pos += 2; + let low = self.hex4()?; + if (0xDC00..0xE000).contains(&low) { + units.push(low); + } else { + self.pos = save; + } + } + out.push_str(&String::from_utf16_lossy(&units)); + } + _ => return Err(self.error("invalid escape")), + } + } + Some(_) => return Err(self.error("control character in string")), + } + } + } +} + +/// `JSON.parse` gives array-index keys first, ascending, then the rest in document order. +fn js_key_order(map: IndexMap) -> IndexMap { + if !map.keys().any(|k| array_index(k).is_some()) { + return map; + } + let (mut indexes, rest): (Vec<_>, Vec<_>) = + map.into_iter().partition(|(k, _)| array_index(k).is_some()); + indexes.sort_by_key(|(k, _)| array_index(k)); + indexes.into_iter().chain(rest).collect() +} + +/// The value of a canonical array-index string ("0", "17", never "07" or "-1"). +pub fn array_index(key: &str) -> Option { + if key.is_empty() + || (key.len() > 1 && key.starts_with('0')) + || !key.bytes().all(|b| b.is_ascii_digit()) + { + return None; + } + key.parse::().ok().filter(|i| *i < u32::MAX) +} + +/// `JSON.stringify(values, null, 2)`. +pub fn stringify_results(values: &[JsValue<'_>]) -> String { + let mut out = String::new(); + write_array(&mut out, values.iter(), values.is_empty(), 0); + out +} + +fn indent(out: &mut String, depth: usize) { + out.push('\n'); + for _ in 0..depth { + out.push_str(" "); + } +} + +fn write_array<'v, 'a: 'v>( + out: &mut String, + items: impl Iterator>, + empty: bool, + depth: usize, +) { + if empty { + out.push_str("[]"); + return; + } + out.push('['); + for (i, item) in items.enumerate() { + if i > 0 { + out.push(','); + } + indent(out, depth + 1); + if !write_js(out, item, depth + 1) { + out.push_str("null"); + } + } + indent(out, depth); + out.push(']'); +} + +/// Writes the value and returns true, or false for what `JSON.stringify` drops. +fn write_js(out: &mut String, value: &JsValue<'_>, depth: usize) -> bool { + match value { + JsValue::Undefined | JsValue::Function(_) => return false, + JsValue::Null => out.push_str("null"), + JsValue::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + JsValue::Number(n) => write_number(out, *n), + JsValue::String(s) => write_string(out, s), + JsValue::Doc(json) => write_json(out, json, depth), + JsValue::Array(array) => { + write_array(out, array.items.iter(), array.items.is_empty(), depth) + } + JsValue::Regex(_) => out.push_str("{}"), + } + true +} + +fn write_json(out: &mut String, json: &Json, depth: usize) { + match json { + Json::Null => out.push_str("null"), + Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Json::Number(n) => write_number(out, *n), + Json::String(s) => write_string(out, s), + Json::Array(items) => { + if items.is_empty() { + out.push_str("[]"); + return; + } + out.push('['); + for (i, item) in items.iter().enumerate() { + if i > 0 { + out.push(','); + } + indent(out, depth + 1); + write_json(out, item, depth + 1); + } + indent(out, depth); + out.push(']'); + } + Json::Object(map) => { + if map.is_empty() { + out.push_str("{}"); + return; + } + out.push('{'); + for (i, (key, item)) in map.iter().enumerate() { + if i > 0 { + out.push(','); + } + indent(out, depth + 1); + write_string(out, key); + out.push_str(": "); + write_json(out, item, depth + 1); + } + indent(out, depth); + out.push('}'); + } + } +} + +fn write_number(out: &mut String, n: f64) { + if n.is_finite() { + out.push_str(&number_to_string(n)); + } else { + out.push_str("null"); + } +} + +fn write_string(out: &mut String, s: &str) { + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\u{8}' => out.push_str("\\b"), + '\u{c}' => out.push_str("\\f"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); +} + +/// `Number.prototype.toString()` for radix 10. +pub fn number_to_string(n: f64) -> String { + if n.is_nan() { + return "NaN".to_string(); + } + if n == 0.0 { + return "0".to_string(); + } + if n.is_infinite() { + return if n > 0.0 { "Infinity" } else { "-Infinity" }.to_string(); + } + let sign = if n < 0.0 { "-" } else { "" }; + let sci = format!("{:e}", n.abs()); + let (mantissa, exponent) = sci.split_once('e').unwrap_or((&sci, "0")); + let digits: String = mantissa.chars().filter(|c| *c != '.').collect(); + let k = digits.len() as i32; + let n_exp = exponent.parse::().unwrap_or(0) + 1; + let body = if k <= n_exp && n_exp <= 21 { + format!("{digits}{}", "0".repeat((n_exp - k) as usize)) + } else if 0 < n_exp && n_exp <= 21 { + format!("{}.{}", &digits[..n_exp as usize], &digits[n_exp as usize..]) + } else if -6 < n_exp && n_exp <= 0 { + format!("0.{}{digits}", "0".repeat((-n_exp) as usize)) + } else { + let e = n_exp - 1; + let e_sign = if e < 0 { "-" } else { "+" }; + let rest = if k > 1 { format!(".{}", &digits[1..]) } else { String::new() }; + format!("{}{rest}e{e_sign}{}", &digits[..1], e.abs()) + }; + format!("{sign}{body}") +} diff --git a/crates/yaak-filter/src/jsonpath/mod.rs b/crates/yaak-filter/src/jsonpath/mod.rs new file mode 100644 index 000000000..82f7b5736 --- /dev/null +++ b/crates/yaak-filter/src/jsonpath/mod.rs @@ -0,0 +1,798 @@ +//! A port of jsonpath-plus 11.0.0 (`JSONPath({ path, json })` with its default +//! options and safe evaluator), so expressions written for the JavaScript +//! filter plugin keep their results. Where it differs on purpose: quoted +//! bracket names are always literal keys, and some RFC 9535 syntax that +//! jsonpath-plus finds nothing for works (`[-1]`, `[::-1]`, `[?@.a > 1]`, +//! `length()`, `count()`, `match()`, `search()`, `value()`). + +mod json; +mod path; +mod regex; +mod rfc; +mod script; +mod value; + +use json::Json; +pub(crate) use json::number_to_string; +use path::Loc; +use regex::JsRegex; +use rfc::Query; +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::LazyLock; +use value::{JsValue, own_property}; + +/// Nested `_trace` calls; a debug build spends about 8 KB of stack on each. +const MAX_DEPTH: usize = 180; +/// Steps a single evaluation may take; `..` after `..` grows exponentially with nesting. +const MAX_STEPS: usize = 20_000_000; + +pub fn evaluate(text: &str, query: &str) -> Result { + let root = json::parse(text).map_err(|e| format!("Invalid JSON: {e}"))?; + if let Some(keys) = path::literal_keys(query) { + let found = keys.iter().try_fold(JsValue::from_doc(&root), |v, k| own_property(&v, k)); + return Ok(json::stringify_results(found.as_slice())); + } + let engine = Engine::new(&root); + let values = + engine.run(query, &JsValue::from_doc(&root)).map_err(|e| format!("Invalid filter: {e}"))?; + Ok(json::stringify_results(&values)) +} + +#[derive(Clone, Debug)] +enum PathElem { + Str(Rc), + Num(f64), +} + +impl PathElem { + fn to_js(&self) -> JsValue<'static> { + match self { + PathElem::Str(s) => JsValue::string(s.to_string()), + PathElem::Num(n) => JsValue::Number(*n), + } + } + + fn text(&self) -> String { + match self { + PathElem::Str(s) => s.to_string(), + PathElem::Num(n) => number_to_string(*n), + } + } +} + +struct PathNode { + elem: PathElem, + prev: Path, +} + +/// The steps from the root, newest first and shared, as jsonpath-plus copies its arrays. +type Path = Option>; + +fn push(path: &Path, elem: PathElem) -> Path { + Some(Rc::new(PathNode { elem, prev: path.clone() })) +} + +/// `JSONPath.toPathString`. +fn path_string(path: &Path) -> String { + let mut elems = Vec::new(); + let mut node = path.as_ref(); + while let Some(n) = node { + elems.push(n.elem.text()); + node = n.prev.as_ref(); + } + elems.reverse(); + let mut out = "$".to_string(); + for elem in elems.iter().skip(1) { + let operator = + elem == "~" || elem == "^" || (elem.starts_with('@') && elem.ends_with("()")); + if operator { + continue; + } + if !elem.is_empty() && elem.bytes().all(|b| b.is_ascii_digit() || b == b'*') { + out.push_str(&format!("[{elem}]")); + } else { + out.push_str(&format!("['{elem}']")); + } + } + out +} + +struct Ret<'a> { + path: Path, + value: JsValue<'a>, + /// The steps after a `^`, to trace again from the parent. + parent_selector: Option>, +} + +enum Traced<'a> { + One(Ret<'a>), + Many(Vec>>), +} + +fn add<'a>(ret: &mut Vec>>, traced: Traced<'a>) { + match traced { + Traced::One(r) => ret.push(Some(r)), + Traced::Many(items) => ret.extend(items), + } +} + +enum FilterPlan { + /// jsonpath-plus spots `@.key[?(...)]` in a filter and keeps the items + /// whose `key` the inner filter selects anything from, ignoring the rest. + Nested { + key: String, + inner: Loc, + }, + Script(Rc