Compare commits

..

15 Commits

Author SHA1 Message Date
Gregory Schier 7b13d46d01 Merge branch 'main' into codex/close-large-upload-responses 2026-07-05 10:44:10 -07:00
Gregory Schier 2c1cf5a13c Restrict privileged workflows to official repo 2026-07-05 09:41:14 -07:00
Gregory Schier b332a0eba9 Fix startup failure from fd exhaustion when launched via Finder (#500)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 08:49:23 -07:00
Gregory Schier f2972ee534 Clarify PR template checkboxes that must always be checked
The "when reasonable" checkboxes read as optional, but the contribution
policy workflow requires all boxes checked. Reword them as either/or
statements and accept the legacy wording in the policy script so
existing PRs keep validating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 08:36:23 -07:00
Gregory Schier 4cad671305 Remove release-notes command
The yaak.app MCP server carries the release workflow in its tool
descriptions and instructions; notes are generated from changelog
items on ship.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 06:56:58 -07:00
Gregory Schier ea33261e08 Update release docs for generated notes and version-locked CLI
The release command no longer teaches hand-writing GitHub notes: they
are generated from the yaak.app changelog items by release_ship_beta
and release_publish. AGENTS.md tag rule updated for the CLI being
version-locked to app tags.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 06:55:21 -07:00
Gregory Schier 4ee080fa49 Add in-app micro-feedback prompts (#497) 2026-07-04 23:21:53 -07:00
Gregory Schier c833aeba78 Fix commit dialog banner layout 2026-07-04 22:24:03 -07:00
Gregory Schier eb2a2dd775 Convert request bodies when changing type (#499) 2026-07-04 22:22:35 -07:00
Gregory Schier 2ac33e351b Close responses before request body history finishes 2026-07-04 17:13:36 -07:00
Gregory Schier e52853cc2d Always render commercial use banner placeholder 2026-07-04 14:44:49 -07:00
Gregory Schier 851d0a26f0 Enable release Tauri features in config 2026-07-04 14:09:41 -07:00
Gregory Schier 78de83c754 Track @yaakapp/cli via the latest dist-tag
The CLI is now version-locked to app releases, so the devDependency
follows the latest stable instead of a range that would strand on the
old 0.x line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:54:19 -07:00
Gregory Schier 9f3fd46d49 Publish CLI on app release tags (version-locked)
The CLI now publishes to npm on every v* tag at the app's version,
instead of its own yaak-cli-* tag namespace. Stables go to the latest
dist-tag, prereleases to beta/alpha. workflow_dispatch remains for
manual publishes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:47:42 -07:00
Gregory Schier 6c42b27edb Build CEF Linux tarballs from debs 2026-07-03 17:35:56 -07:00
51 changed files with 1611 additions and 341 deletions
@@ -1,50 +0,0 @@
---
description: Generate formatted release notes for Yaak releases
allowed-tools: Bash(git tag:*)
---
Generate formatted release notes for Yaak releases by analyzing git history and pull request descriptions.
## What to do
1. Identifies the version tag and previous version
2. Retrieves all commits between versions
- If the version is a beta version, it retrieves commits between the beta version and previous beta version
- If the version is a stable version, it retrieves commits between the stable version and the previous stable version
3. Fetches PR descriptions for linked issues to find:
- Feedback URLs (feedback.yaak.app)
- Additional context and descriptions
- Installation links for plugins
4. Formats the release notes using the standard Yaak format:
- Changelog badge at the top
- Bulleted list of changes with PR links
- Feedback links where available
- Full changelog comparison link at the bottom
## Output Format
The skill generates markdown-formatted release notes following this structure:
```markdown
[![Changelog](https://img.shields.io/badge/Changelog-VERSION-blue)](https://yaak.app/changelog/VERSION)
- Feature/fix description in by @username [#123](https://github.com/mountain-loop/yaak/pull/123)
- [Linked feedback item](https://feedback.yaak.app/p/item) by @username in [#456](https://github.com/mountain-loop/yaak/pull/456)
- A simple item that doesn't have a feedback or PR link
**Full Changelog**: https://github.com/mountain-loop/yaak/compare/vPREV...vCURRENT
```
**IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last
**IMPORTANT**: PRs by `@gschier` should not mention the @username
**IMPORTANT**: These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.
## After Generating Release Notes
After outputting the release notes, ask the user if they would like to create a draft GitHub release with these notes. If they confirm, create the release using:
```bash
gh release create <tag> --draft --prerelease --title "Release <version>" --notes '<release notes>'
```
**IMPORTANT**: The release title format is "Release XXXX" where XXXX is the version WITHOUT the `v` prefix. For example, tag `v2026.2.1-beta.1` gets title "Release 2026.2.1-beta.1".
+4 -2
View File
@@ -4,12 +4,14 @@
## Submission ## Submission
<!-- Check every box below except at most one of the first two (bug fixes only need the first). The last two must be checked even when they do not apply — checking confirms you considered them. -->
- [ ] This PR is a bug fix. - [ ] This PR is a bug fix.
- [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it. - [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md). - [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
- [ ] I tested this change locally. - [ ] I tested this change locally.
- [ ] I added or updated tests when reasonable. - [ ] I added or updated tests, or tests are not reasonable for this change.
- [ ] I added screenshots or recordings for UI changes when reasonable. - [ ] I added screenshots or recordings, or this change does not affect the UI.
Explicit permission feedback item (required if not a bug fix): Explicit permission feedback item (required if not a bug fix):
+21 -11
View File
@@ -53,16 +53,25 @@ const MANAGED_LABEL_NAMES = [
...new Set(Object.values(LABELS).map((label) => label.name)), ...new Set(Object.values(LABELS).map((label) => label.name)),
]; ];
// Each checkbox lists its current label first, followed by legacy labels still
// accepted from PRs opened against older versions of the template.
const CHECKBOXES = { const CHECKBOXES = {
bugFix: "This PR is a bug fix.", bugFix: ["This PR is a bug fix."],
explicitPermission: explicitPermission: [
"If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.", "If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.",
readContributing: ],
readContributing: [
"I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).", "I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).",
testedLocally: "I tested this change locally.", ],
testsUpdated: "I added or updated tests when reasonable.", testedLocally: ["I tested this change locally."],
screenshotsAdded: testsUpdated: [
"I added or updated tests, or tests are not reasonable for this change.",
"I added or updated tests when reasonable.",
],
screenshotsAdded: [
"I added screenshots or recordings, or this change does not affect the UI.",
"I added screenshots or recordings for UI changes when reasonable.", "I added screenshots or recordings for UI changes when reasonable.",
],
}; };
function escapeRegExp(value) { function escapeRegExp(value) {
@@ -102,8 +111,8 @@ function normalizeCheckboxLabel(label) {
.trim(); .trim();
} }
function checkboxState(body, label) { function checkboxState(body, labels) {
const expectedLabel = normalizeCheckboxLabel(label); const expectedLabels = new Set(labels.map(normalizeCheckboxLabel));
for (const line of body.split("\n")) { for (const line of body.split("\n")) {
const match = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*?)\s*$/i); const match = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*?)\s*$/i);
@@ -112,7 +121,7 @@ function checkboxState(body, label) {
continue; continue;
} }
if (normalizeCheckboxLabel(match[2]) === expectedLabel) { if (expectedLabels.has(normalizeCheckboxLabel(match[2]))) {
return match[1].toLowerCase() === "x"; return match[1].toLowerCase() === "x";
} }
} }
@@ -255,7 +264,8 @@ function analyzePullRequest(pr) {
if (states.testsUpdated !== true) { if (states.testsUpdated !== true) {
blockers.push({ blockers.push({
label: LABELS.policyUnmet.name, label: LABELS.policyUnmet.name,
message: "Confirm that tests were added or updated when reasonable.", message:
"Confirm that tests were added or updated, or that tests are not reasonable for this change. Check the box either way.",
}); });
} }
@@ -263,7 +273,7 @@ function analyzePullRequest(pr) {
blockers.push({ blockers.push({
label: LABELS.policyUnmet.name, label: LABELS.policyUnmet.name,
message: message:
"Confirm that screenshots or recordings were added for UI changes when reasonable.", "Confirm that screenshots or recordings were added, or that this change does not affect the UI. Check the box either way.",
}); });
} }
} }
-49
View File
@@ -1,49 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
@@ -30,6 +30,7 @@ permissions:
jobs: jobs:
check: check:
if: github.repository == 'mountain-loop/yaak'
name: Check contribution policy name: Check contribution policy
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+1
View File
@@ -13,6 +13,7 @@ permissions:
jobs: jobs:
update-flathub: update-flathub:
if: github.repository == 'mountain-loop/yaak'
name: Update Flathub manifest name: Update Flathub manifest
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+1
View File
@@ -15,6 +15,7 @@ permissions:
jobs: jobs:
publish-npm: publish-npm:
if: github.repository == 'mountain-loop/yaak'
name: Publish @yaakapp/api name: Publish @yaakapp/api
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
+21 -4
View File
@@ -5,6 +5,7 @@ on:
jobs: jobs:
build-artifacts: build-artifacts:
if: github.repository == 'mountain-loop/yaak'
permissions: permissions:
contents: write contents: write
@@ -39,18 +40,18 @@ jobs:
targets: "" targets: ""
- platform: "ubuntu-22.04" - platform: "ubuntu-22.04"
args: >- args: >-
--bundles appimage --bundles deb
--config ./tauri.release.conf.json --config ./tauri.release.conf.json
--config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false,"linux":{"appimage":{"files":{"/usr/lib/libfreebl3.chk":"/usr/lib/x86_64-linux-gnu/libfreebl3.chk","/usr/lib/libfreebl3.so":"/usr/lib/x86_64-linux-gnu/libfreebl3.so","/usr/lib/libfreeblpriv3.chk":"/usr/lib/x86_64-linux-gnu/libfreeblpriv3.chk","/usr/lib/libfreeblpriv3.so":"/usr/lib/x86_64-linux-gnu/libfreeblpriv3.so","/usr/lib/libsoftokn3.chk":"/usr/lib/x86_64-linux-gnu/nss/libsoftokn3.chk","/usr/lib/libsoftokn3.so":"/usr/lib/x86_64-linux-gnu/nss/libsoftokn3.so","/usr/lib/nss/libfreebl3.chk":"/usr/lib/x86_64-linux-gnu/nss/libfreebl3.chk","/usr/lib/nss/libfreebl3.so":"/usr/lib/x86_64-linux-gnu/nss/libfreebl3.so","/usr/lib/nss/libfreeblpriv3.chk":"/usr/lib/x86_64-linux-gnu/nss/libfreeblpriv3.chk","/usr/lib/nss/libfreeblpriv3.so":"/usr/lib/x86_64-linux-gnu/nss/libfreeblpriv3.so","/usr/lib/nss/libnssckbi.so":"/usr/lib/x86_64-linux-gnu/nss/libnssckbi.so","/usr/lib/nss/libnssdbm3.chk":"/usr/lib/x86_64-linux-gnu/nss/libnssdbm3.chk","/usr/lib/nss/libnssdbm3.so":"/usr/lib/x86_64-linux-gnu/nss/libnssdbm3.so","/usr/lib/nss/libsoftokn3.chk":"/usr/lib/x86_64-linux-gnu/nss/libsoftokn3.chk","/usr/lib/nss/libsoftokn3.so":"/usr/lib/x86_64-linux-gnu/nss/libsoftokn3.so"}}}}}' --config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false}}'
yaak_arch: "x64" yaak_arch: "x64"
os: "ubuntu" os: "ubuntu"
runtime: "cef" runtime: "cef"
targets: "" targets: ""
- platform: "ubuntu-22.04-arm" - platform: "ubuntu-22.04-arm"
args: >- args: >-
--bundles appimage --bundles deb
--config ./tauri.release.conf.json --config ./tauri.release.conf.json
--config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false,"linux":{"appimage":{"files":{"/usr/lib/libfreebl3.chk":"/usr/lib/aarch64-linux-gnu/libfreebl3.chk","/usr/lib/libfreebl3.so":"/usr/lib/aarch64-linux-gnu/libfreebl3.so","/usr/lib/libfreeblpriv3.chk":"/usr/lib/aarch64-linux-gnu/libfreeblpriv3.chk","/usr/lib/libfreeblpriv3.so":"/usr/lib/aarch64-linux-gnu/libfreeblpriv3.so","/usr/lib/libsoftokn3.chk":"/usr/lib/aarch64-linux-gnu/nss/libsoftokn3.chk","/usr/lib/libsoftokn3.so":"/usr/lib/aarch64-linux-gnu/nss/libsoftokn3.so","/usr/lib/nss/libfreebl3.chk":"/usr/lib/aarch64-linux-gnu/nss/libfreebl3.chk","/usr/lib/nss/libfreebl3.so":"/usr/lib/aarch64-linux-gnu/nss/libfreebl3.so","/usr/lib/nss/libfreeblpriv3.chk":"/usr/lib/aarch64-linux-gnu/nss/libfreeblpriv3.chk","/usr/lib/nss/libfreeblpriv3.so":"/usr/lib/aarch64-linux-gnu/nss/libfreeblpriv3.so","/usr/lib/nss/libnssckbi.so":"/usr/lib/aarch64-linux-gnu/nss/libnssckbi.so","/usr/lib/nss/libnssdbm3.chk":"/usr/lib/aarch64-linux-gnu/nss/libnssdbm3.chk","/usr/lib/nss/libnssdbm3.so":"/usr/lib/aarch64-linux-gnu/nss/libnssdbm3.so","/usr/lib/nss/libsoftokn3.chk":"/usr/lib/aarch64-linux-gnu/nss/libsoftokn3.chk","/usr/lib/nss/libsoftokn3.so":"/usr/lib/aarch64-linux-gnu/nss/libsoftokn3.so"}}}}}' --config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false}}'
yaak_arch: "arm64" yaak_arch: "arm64"
os: "ubuntu" os: "ubuntu"
runtime: "cef" runtime: "cef"
@@ -190,6 +191,22 @@ jobs:
projectPath: ./crates-tauri/yaak-app-client projectPath: ./crates-tauri/yaak-app-client
args: "${{ matrix.args }}" args: "${{ matrix.args }}"
- name: Build and upload CEF tarball from deb (Linux only)
if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
deb=$(find target/release/bundle/deb -maxdepth 1 -type f -name '*.deb' | head -n 1)
version="${GITHUB_REF_NAME#v}"
extract_dir="target/release/bundle/deb/yaak-cef-linux-${{ matrix.yaak_arch }}"
tarball="target/release/bundle/deb/yaak-cef_${version}_linux_${{ matrix.yaak_arch }}.tar.gz"
rm -rf "$extract_dir"
mkdir -p "$extract_dir"
dpkg-deb -x "$deb" "$extract_dir"
tar -C "$extract_dir" -czf "$tarball" .
gh release upload "${{ github.ref_name }}" "$tarball" --clobber
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune) # Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
- name: Build and upload machine-wide installer (Windows only) - name: Build and upload machine-wide installer (Windows only)
if: matrix.os == 'windows' if: matrix.os == 'windows'
+4 -3
View File
@@ -2,7 +2,7 @@ name: Release CLI to NPM
on: on:
push: push:
tags: [yaak-cli-*] tags: [v*]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
@@ -15,6 +15,7 @@ permissions:
jobs: jobs:
prepare-vendored-assets: prepare-vendored-assets:
if: github.repository == 'mountain-loop/yaak'
name: Prepare vendored plugin assets name: Prepare vendored plugin assets
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -118,7 +119,7 @@ jobs:
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION" VERSION="$WORKFLOW_VERSION"
else else
VERSION="${GITHUB_REF_NAME#yaak-cli-}" VERSION="${GITHUB_REF_NAME}"
fi fi
VERSION="${VERSION#v}" VERSION="${VERSION#v}"
echo "Building yaak version: $VERSION" echo "Building yaak version: $VERSION"
@@ -175,7 +176,7 @@ jobs:
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION" VERSION="$WORKFLOW_VERSION"
else else
VERSION="${GITHUB_REF_NAME#yaak-cli-}" VERSION="${GITHUB_REF_NAME}"
fi fi
VERSION="${VERSION#v}" VERSION="${VERSION#v}"
if [[ "$VERSION" == *-* ]]; then if [[ "$VERSION" == *-* ]]; then
+1
View File
@@ -7,6 +7,7 @@ permissions:
contents: write contents: write
jobs: jobs:
deploy: deploy:
if: github.repository == 'mountain-loop/yaak'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout 🛎️ - name: Checkout 🛎️
+1 -1
View File
@@ -1,2 +1,2 @@
- Tag safety: app releases use `v*` tags and CLI releases use `yaak-cli-*` tags; always confirm which one is requested before retagging. - Tag safety: app AND CLI releases both ship from `v*` tags (the CLI is version-locked to the app and publishes to npm on every app tag); `@yaakapp/api` uses `yaak-api-*` tags. Always confirm which is requested before retagging.
- Do not commit, push, or tag without explicit approval - Do not commit, push, or tag without explicit approval
Generated
+12 -2
View File
@@ -3885,9 +3885,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.172" version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]] [[package]]
name = "libdbus-sys" name = "libdbus-sys"
@@ -6649,6 +6649,15 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "rlimit"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f35ee2729c56bb610f6dba436bf78135f728b7373bdffae2ec815b2d3eb98cc3"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "rolldown" name = "rolldown"
version = "0.1.0" version = "0.1.0"
@@ -10932,6 +10941,7 @@ dependencies = [
"r2d2_sqlite", "r2d2_sqlite",
"rand 0.9.1", "rand 0.9.1",
"reqwest 0.12.20", "reqwest 0.12.20",
"rlimit",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@@ -0,0 +1,76 @@
import { HStack, VStack } from "@yaakapp-internal/ui";
import { useRef, useState } from "react";
import type { FeedbackFeature } from "../lib/featureFeedbackConstants";
import { FEEDBACK_FEATURES } from "../lib/featureFeedbackConstants";
import { invokeCmd } from "../lib/tauri";
import { hideToastById, showToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Input } from "./core/Input";
interface Props {
feature: FeedbackFeature;
onDone: () => void;
}
export function FeedbackToast({ feature, onDone }: Props) {
const [text, setText] = useState<string>("");
const [sent, setSent] = useState(false);
const sentRef = useRef(false);
const handleDismiss = () => {
onDone();
hideToastById(`feature-feedback-${feature}`);
};
const handleSend = () => {
const trimmedText = text.trim();
if (sentRef.current || trimmedText.length === 0) return;
sentRef.current = true;
setSent(true);
onDone();
// Fire-and-forget; failures are intentionally ignored
invokeCmd("cmd_send_feedback", { feature, text: trimmedText }).catch(() => {});
showToast({
id: `feature-feedback-${feature}`,
timeout: 3000,
color: "success",
message: "Thanks for the feedback!",
});
};
return (
<VStack space={2}>
<p className="text-sm font-semibold">{FEEDBACK_FEATURES[feature]}</p>
<div className="h-20">
<Input
size="xs"
// The editor forces its mono font on the scroller, so the override
// has to target it directly
className="[&_.cm-scroller]:font-sans! [&_.cm-scroller]:text-sm!"
label="Feedback"
hideLabel
stateKey={null}
multiLine
fullHeight
placeholder="Your thoughts..."
onChange={setText}
/>
</div>
<HStack space={1.5} justifyContent="end">
<Button size="xs" color="secondary" variant="border" onClick={handleDismiss}>
Dismiss
</Button>
<Button
size="xs"
color="primary"
disabled={sent || text.trim().length === 0}
onClick={handleSend}
>
Send
</Button>
</HStack>
</VStack>
);
}
@@ -20,6 +20,7 @@ import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType"; import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId"; import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders"; import { extractPathPlaceholders } from "../lib/pathPlaceholders";
import { convertRequestBody } from "../lib/requestBodyConversion";
import { import {
BODY_TYPE_BINARY, BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART, BODY_TYPE_FORM_MULTIPART,
@@ -195,7 +196,14 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
}); });
}; };
const patch: Partial<HttpRequest> = { bodyType }; const patch: Partial<HttpRequest> = {
bodyType,
body: convertRequestBody({
body: activeRequest.body,
fromBodyType: activeRequest.bodyType,
toBodyType: bodyType,
}),
};
let newContentType: string | null | undefined; let newContentType: string | null | undefined;
if (bodyType === BODY_TYPE_NONE) { if (bodyType === BODY_TYPE_NONE) {
newContentType = null; newContentType = null;
@@ -92,7 +92,9 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
</HStack> </HStack>
), ),
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />, leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedResponseId(r.id), onSelect: () => {
onPinnedResponseId(r.id);
},
}); });
} }
@@ -112,6 +112,17 @@ export function SettingsGeneral() {
</SettingsSection> </SettingsSection>
</CargoFeature> </CargoFeature>
<CargoFeature feature="license">
<SettingsSection title="Feedback">
<SettingRowBoolean
title="Prompt for feedback"
description="Show rare one-time prompts asking how new features are working."
checked={settings.promptFeedback}
onChange={(promptFeedback) => patchModel(settings, { promptFeedback })}
/>
</SettingsSection>
</CargoFeature>
{showWorkspaceSettingsMovedBanner && ( {showWorkspaceSettingsMovedBanner && (
<DismissibleBanner <DismissibleBanner
id="workspace-settings-moved-2026-06-30" id="workspace-settings-moved-2026-06-30"
@@ -318,6 +318,7 @@ function BaseInput({
editorClassName, editorClassName,
multiLine && size === "md" && "py-1.5", multiLine && size === "md" && "py-1.5",
multiLine && size === "sm" && "py-1", multiLine && size === "sm" && "py-1",
multiLine && (size === "xs" || size === "2xs") && "py-0.5",
)} )}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
+69 -11
View File
@@ -3,6 +3,7 @@ import { Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import * as m from "motion/react-m"; import * as m from "motion/react-m";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useKey } from "react-use"; import { useKey } from "react-use";
import { IconButton } from "./IconButton"; import { IconButton } from "./IconButton";
@@ -15,6 +16,12 @@ export interface ToastProps {
action?: (args: { hide: () => void }) => ReactNode; action?: (args: { hide: () => void }) => ReactNode;
icon?: ShowToastRequest["icon"] | null; icon?: ShowToastRequest["icon"] | null;
color?: ShowToastRequest["color"]; 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<NonNullable<ToastProps["color"] | "custom">, IconProps["icon"] | null> = { const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon"] | null> = {
@@ -28,7 +35,47 @@ const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon
warning: "alert_triangle", warning: "alert_triangle",
}; };
export function Toast({ children, open, onClose, timeout, action, icon, color }: ToastProps) { export function Toast({
children,
open,
onClose,
timeout,
action,
icon,
color,
dynamicHeight,
hideDismiss,
}: ToastProps) {
const onCloseRef = useRef(onClose);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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( useKey(
"Escape", "Escape",
() => { () => {
@@ -56,8 +103,17 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
"relative pointer-events-auto bg-surface text-text rounded-lg", "relative pointer-events-auto bg-surface text-text rounded-lg",
"border border-border shadow-lg w-100", "border border-border shadow-lg w-100",
)} )}
onFocusCapture={cancelAutoHide}
onKeyDownCapture={cancelAutoHide}
onPointerDownCapture={cancelAutoHide}
> >
<div className="pl-3 py-3 pr-10 flex items-start gap-2 w-full max-h-44 overflow-auto"> <div
className={classNames(
"pl-3 py-3 flex items-start gap-2 w-full overflow-auto",
hideDismiss ? "pr-3" : "pr-10",
dynamicHeight ? "max-h-[80vh]" : "max-h-44",
)}
>
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />} {toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />}
<VStack space={2} className="w-full min-w-0"> <VStack space={2} className="w-full min-w-0">
<div className="select-auto">{children}</div> <div className="select-auto">{children}</div>
@@ -65,16 +121,18 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
</VStack> </VStack>
</div> </div>
<IconButton {!hideDismiss && (
color={color} <IconButton
variant="border" color={color}
className="opacity-60 border-0 absolute! top-2 right-2" variant="border"
title="Dismiss" className="opacity-60 border-0 absolute! top-2 right-2"
icon="x" title="Dismiss"
onClick={onClose} icon="x"
/> onClick={onClose}
/>
)}
{timeout != null && ( {timeout != null && !autoHideCanceled && (
<div className="w-full absolute bottom-0 left-0 right-0"> <div className="w-full absolute bottom-0 left-0 right-0">
<m.div <m.div
className="bg-surface-highlight h-[3px]" className="bg-surface-highlight h-[3px]"
@@ -12,6 +12,7 @@ import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal
import classNames from "classnames"; import classNames from "classnames";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { modelToYaml } from "../../lib/diffYaml"; import { modelToYaml } from "../../lib/diffYaml";
import { trackFeatureUsage } from "../../lib/featureFeedback";
import { resolvedModelName } from "../../lib/resolvedModelName"; import { resolvedModelName } from "../../lib/resolvedModelName";
import { showConfirm } from "../../lib/confirm"; import { showConfirm } from "../../lib/confirm";
import { showErrorToast } from "../../lib/toast"; import { showErrorToast } from "../../lib/toast";
@@ -55,6 +56,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
setCommitError(null); setCommitError(null);
try { try {
await commit.mutateAsync({ message }); await commit.mutateAsync({ message });
trackFeatureUsage("git-sync");
onDone(); onDone();
} catch (err) { } catch (err) {
setCommitError(String(err)); setCommitError(String(err));
@@ -66,6 +68,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
try { try {
const r = await commitAndPush.mutateAsync({ message }); const r = await commitAndPush.mutateAsync({ message });
handlePushResult(r); handlePushResult(r);
trackFeatureUsage("git-sync");
onDone(); onDone();
} catch (err) { } catch (err) {
showErrorToast({ showErrorToast({
@@ -206,9 +209,10 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
layout="horizontal" layout="horizontal"
defaultRatio={0.6} defaultRatio={0.6}
firstSlot={({ style }) => ( firstSlot={({ style }) => (
<div style={style} className="h-full px-4 grid grid-rows-[auto_minmax(0,1fr)] gap-3"> <div style={style} className="h-full px-4 flex flex-col gap-3">
<CommercialUseBanner source="git-commit" title="Using Git for work?" /> <CommercialUseBanner source="git-commit" title="Using Git for work?" />
<SplitLayout <SplitLayout
className="min-h-0 flex-1"
storageKey="commit-vertical" storageKey="commit-vertical"
layout="vertical" layout="vertical"
defaultRatio={0.35} defaultRatio={0.35}
@@ -239,7 +243,10 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
</div> </div>
)} )}
secondSlot={({ style: innerStyle }) => ( secondSlot={({ style: innerStyle }) => (
<div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2"> <div
style={innerStyle}
className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2"
>
<Input <Input
className="text-base! font-sans rounded-md" className="text-base! font-sans rounded-md"
placeholder="Commit message..." placeholder="Commit message..."
@@ -1,7 +1,7 @@
import type { HttpRequest } from "@yaakapp-internal/models"; import type { HttpRequest } from "@yaakapp-internal/models";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { useLocalStorage } from "react-use"; import { useLocalStorage } from "react-use";
import { useIntrospectGraphQL } from "../../hooks/useIntrospectGraphQL"; import { useIntrospectGraphQL } from "../../hooks/useIntrospectGraphQL";
import { useStateWithDeps } from "../../hooks/useStateWithDeps"; import { useStateWithDeps } from "../../hooks/useStateWithDeps";
@@ -11,9 +11,13 @@ import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown"; import { Dropdown } from "../core/Dropdown";
import type { EditorProps } from "../core/Editor/Editor"; import type { EditorProps } from "../core/Editor/Editor";
import { Editor } from "../core/Editor/LazyEditor"; import { Editor } from "../core/Editor/LazyEditor";
import type { RadioDropdownItem } from "../core/RadioDropdown";
import { RadioDropdown } from "../core/RadioDropdown";
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui"; import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
import { Separator } from "../core/Separator"; import { Separator } from "../core/Separator";
import { tryFormatGraphql } from "../../lib/formatters"; import { tryFormatGraphql } from "../../lib/formatters";
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms"; import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & { type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
@@ -22,6 +26,8 @@ type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> &
request: HttpRequest; request: HttpRequest;
}; };
const OPERATION_NAME_NOT_SPECIFIED = "";
export function GraphQLEditor(props: Props) { export function GraphQLEditor(props: Props) {
// There's some weirdness with stale onChange being called when switching requests, so we'll // There's some weirdness with stale onChange being called when switching requests, so we'll
// key on the request ID as a workaround for now. // key on the request ID as a workaround for now.
@@ -38,25 +44,25 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
const [currentBody, setCurrentBody] = useStateWithDeps<{ const [currentBody, setCurrentBody] = useStateWithDeps<{
query: string; query: string;
variables: string | undefined; variables: string | undefined;
operationName?: string;
}>(() => { }>(() => {
// Migrate text bodies to GraphQL format // Migrate text bodies to GraphQL format
// NOTE: This is how GraphQL used to be stored // NOTE: This is how GraphQL used to be stored
if ("text" in request.body) { return normalizeGraphQLBody(request.body);
const b = tryParseJson(request.body.text, {});
const variables = JSON.stringify(b.variables || undefined, null, 2);
return { query: b.query ?? "", variables };
}
return { query: request.body.query ?? "", variables: request.body.variables ?? "" };
}, [extraEditorProps.forceUpdateKey]); }, [extraEditorProps.forceUpdateKey]);
const [isDocOpenRecord, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom); const [isDocOpenRecord, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom);
const isDocOpen = isDocOpenRecord[request.id] !== undefined; const isDocOpen = isDocOpenRecord[request.id] !== undefined;
const parsedOperationNames = useMemo(
() => parseGraphQLOperationNames(currentBody.query),
[currentBody.query],
);
const operationNames = useMemo(() => parsedOperationNames ?? [], [parsedOperationNames]);
const handleChangeQuery = useCallback( const handleChangeQuery = useCallback(
(query: string) => { (query: string) => {
setCurrentBody(({ variables }) => { setCurrentBody(({ variables, operationName }) => {
const newBody = { query, variables }; const newBody = buildGraphQLBody({ query, variables, operationName });
onChange(newBody); onChange(newBody);
return newBody; return newBody;
}); });
@@ -66,8 +72,8 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
const handleChangeVariables = useCallback( const handleChangeVariables = useCallback(
(variables: string) => { (variables: string) => {
setCurrentBody(({ query }) => { setCurrentBody(({ query, operationName }) => {
const newBody = { query, variables: variables || undefined }; const newBody = buildGraphQLBody({ query, variables, operationName });
onChange(newBody); onChange(newBody);
return newBody; return newBody;
}); });
@@ -75,125 +81,196 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
[onChange, setCurrentBody], [onChange, setCurrentBody],
); );
const handleChangeOperationName = useCallback(
(operationName: string) => {
setCurrentBody(({ query, variables }) => {
const newBody = buildGraphQLBody({ query, variables, operationName });
onChange(newBody);
return newBody;
});
},
[onChange, setCurrentBody],
);
useEffect(() => {
if (parsedOperationNames == null) {
return;
}
if (currentBody.operationName === OPERATION_NAME_NOT_SPECIFIED) {
return;
}
if (currentBody.operationName && operationNames.includes(currentBody.operationName)) {
return;
}
// Keep the saved body aligned with the visible default, so send/copy use the selected operation.
const operationName = operationNames[0];
if (currentBody.operationName === operationName) {
return;
}
setCurrentBody(({ query, variables }) => {
const newBody = buildGraphQLBody({ query, variables, operationName });
onChange(newBody);
return newBody;
});
}, [
currentBody.operationName,
onChange,
operationNames,
parsedOperationNames,
setCurrentBody,
]);
const actions = useMemo<EditorProps["actions"]>( const actions = useMemo<EditorProps["actions"]>(
() => [ () => [
<div key="actions" className="flex flex-row opacity-100! shadow!"> operationNames.length > 0 ? (
<div key="introspection" className="opacity-100!"> <div key="operation" className="opacity-100!">
{schema === undefined ? null /* Initializing */ : ( <RadioDropdown
<Dropdown value={currentBody.operationName ?? operationNames[0] ?? OPERATION_NAME_NOT_SPECIFIED}
items={[ onChange={handleChangeOperationName}
...((schema != null items={[
? [ { type: "separator", label: "Operation Name" },
{ {
label: "Clear", label: <span className="text-text-subtle italic">Not specified</span>,
onSelect: clear, value: OPERATION_NAME_NOT_SPECIFIED,
color: "danger", },
leftSlot: <Icon icon="trash" />, ...operationNames.map((operationName) => ({
}, label: operationName,
{ type: "separator" }, value: operationName,
] })),
: []) satisfies DropdownItem[]), ] satisfies RadioDropdownItem<string>[]}
{ >
hidden: !error, <Button size="sm" variant="border" title="Select Operation" forDropdown>
label: ( {currentBody.operationName === OPERATION_NAME_NOT_SPECIFIED ? (
<Banner color="danger"> <span className="text-text-subtle italic">Not specified</span>
<p className="mb-1">Schema introspection failed</p> ) : (
<Button currentBody.operationName ?? operationNames[0]
size="xs" )}
color="danger" </Button>
variant="border" </RadioDropdown>
onClick={() => {
showDialog({
title: "Introspection Failed",
size: "sm",
id: "introspection-failed",
render: ({ hide }) => (
<>
<FormattedError>{error ?? "unknown"}</FormattedError>
<div className="w-full my-4">
<Button
onClick={async () => {
hide();
await refetch();
}}
className="ml-auto"
color="primary"
size="sm"
>
Retry Request
</Button>
</div>
</>
),
});
}}
>
View Error
</Button>
</Banner>
),
type: "content",
},
{
hidden: schema == null,
label: `${isDocOpen ? "Hide" : "Show"} Documentation`,
leftSlot: <Icon icon="book_open_text" />,
onSelect: () => {
setGraphqlDocStateAtomValue((v) => ({
...v,
[request.id]: isDocOpen ? undefined : null,
}));
},
},
{
label: "Introspect Schema",
leftSlot: <Icon icon="refresh" spin={isLoading} />,
keepOpenOnSelect: true,
onSelect: refetch,
},
{ type: "separator", label: "Setting" },
{
label: "Automatic Introspection",
keepOpenOnSelect: true,
onSelect: () => {
setAutoIntrospectDisabled({
...autoIntrospectDisabled,
[baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id],
});
},
leftSlot: (
<Icon
icon={
autoIntrospectDisabled?.[baseRequest.id]
? "check_square_unchecked"
: "check_square_checked"
}
/>
),
},
]}
>
<Button
size="sm"
variant="border"
title="Refetch Schema"
isLoading={isLoading}
color={error ? "danger" : "default"}
forDropdown
>
{error ? "Introspection Failed" : schema ? "Schema" : "No Schema"}
</Button>
</Dropdown>
)}
</div> </div>
) : null,
<div key="introspection" className="opacity-100!">
{schema === undefined ? null /* Initializing */ : (
<Dropdown
items={[
...((schema != null
? [
{
label: "Clear",
onSelect: clear,
color: "danger",
leftSlot: <Icon icon="trash" />,
},
{ type: "separator" },
]
: []) satisfies DropdownItem[]),
{
hidden: !error,
label: (
<Banner color="danger">
<p className="mb-1">Schema introspection failed</p>
<Button
size="xs"
color="danger"
variant="border"
onClick={() => {
showDialog({
title: "Introspection Failed",
size: "sm",
id: "introspection-failed",
render: ({ hide }) => (
<>
<FormattedError>{error ?? "unknown"}</FormattedError>
<div className="w-full my-4">
<Button
onClick={async () => {
hide();
await refetch();
}}
className="ml-auto"
color="primary"
size="sm"
>
Retry Request
</Button>
</div>
</>
),
});
}}
>
View Error
</Button>
</Banner>
),
type: "content",
},
{
hidden: schema == null,
label: `${isDocOpen ? "Hide" : "Show"} Documentation`,
leftSlot: <Icon icon="book_open_text" />,
onSelect: () => {
setGraphqlDocStateAtomValue((v) => ({
...v,
[request.id]: isDocOpen ? undefined : null,
}));
},
},
{
label: "Introspect Schema",
leftSlot: <Icon icon="refresh" spin={isLoading} />,
keepOpenOnSelect: true,
onSelect: refetch,
},
{ type: "separator", label: "Setting" },
{
label: "Automatic Introspection",
keepOpenOnSelect: true,
onSelect: () => {
setAutoIntrospectDisabled({
...autoIntrospectDisabled,
[baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id],
});
},
leftSlot: (
<Icon
icon={
autoIntrospectDisabled?.[baseRequest.id]
? "check_square_unchecked"
: "check_square_checked"
}
/>
),
},
]}
>
<Button
size="sm"
variant="border"
title="Refetch Schema"
isLoading={isLoading}
color={error ? "danger" : "default"}
forDropdown
>
{error ? "Introspection Failed" : schema ? "Schema" : "No Schema"}
</Button>
</Dropdown>
)}
</div>, </div>,
], ],
[ [
schema, schema,
clear, clear,
error, error,
currentBody.operationName,
handleChangeOperationName,
isDocOpen, isDocOpen,
isLoading, isLoading,
operationNames,
refetch, refetch,
autoIntrospectDisabled, autoIntrospectDisabled,
baseRequest.id, baseRequest.id,
@@ -237,10 +314,23 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
); );
} }
function tryParseJson(text: string, fallback: unknown) { function buildGraphQLBody(body: {
try { query: string;
return JSON.parse(text); variables: string | undefined;
} catch { operationName?: string;
return fallback; }) {
const result: {
query: string;
variables: string | undefined;
operationName?: string;
} = {
query: body.query,
variables: body.variables || undefined,
};
if (typeof body.operationName === "string") {
result.operationName = body.operationName;
} }
return result;
} }
@@ -71,6 +71,7 @@ function ActualEventStreamViewer({ response }: Props) {
summary.data.fragmentCount === 0 && summary.data.fragmentCount === 0 &&
!summary.isFetching && !summary.isFetching &&
summary.error == null; summary.error == null;
const filterEventPreviews = showExtractedText && filterEventPreviewsSetting.value === true; const filterEventPreviews = showExtractedText && filterEventPreviewsSetting.value === true;
const applyToDetails = showExtractedText && applyToDetailsSetting.value === true; const applyToDetails = showExtractedText && applyToDetailsSetting.value === true;
const renderMarkdown = showExtractedText && renderMarkdownSetting.value === true; const renderMarkdown = showExtractedText && renderMarkdownSetting.value === true;
+103
View File
@@ -0,0 +1,103 @@
import { settingsAtom } from "@yaakapp-internal/models";
import { FeedbackToast } from "../components/FeedbackToast";
import { appInfo } from "./appInfo";
import type { FeedbackFeature } from "./featureFeedbackConstants";
import { dialogsAtom } from "./dialog";
import { jotaiStore } from "./jotai";
import { getKeyValue, setKeyValue } from "./keyValueStore";
import { showToast } from "./toast";
interface FeatureFeedbackState {
uses: number;
done: boolean;
}
const FEEDBACK_PROMPT_DELAY_MS = 1500;
const FEEDBACK_PROMPT_TIMEOUT_MS = 8000;
// Ask once the user has used a feature enough times to have formed an opinion
const PROMPT_AFTER_USES = 3;
// Show at most one feedback prompt per app session to stay unobtrusive
let promptedThisSession = false;
const lastTrackedAt: Partial<Record<FeedbackFeature, number>> = {};
const FEATURE_USE_DEBOUNCE_MS = 10_000;
const kvArgs = (feature: FeedbackFeature) => ({
namespace: "global",
key: ["feature-feedback", feature],
});
function getFeatureFeedbackState(feature: FeedbackFeature): FeatureFeedbackState {
return getKeyValue<FeatureFeedbackState>({
...kvArgs(feature),
fallback: { uses: 0, done: false },
});
}
function patchFeatureFeedbackState(feature: FeedbackFeature, patch: Partial<FeatureFeedbackState>) {
const value = { ...getFeatureFeedbackState(feature), ...patch };
setKeyValue({ ...kvArgs(feature), value }).catch(console.error);
}
function markFeatureFeedbackDone(feature: FeedbackFeature) {
patchFeatureFeedbackState(feature, { done: true });
}
function showFeedbackToast(feature: FeedbackFeature) {
if (!jotaiStore.get(settingsAtom).promptFeedback) return;
showToast({
id: `feature-feedback-${feature}`,
timeout: FEEDBACK_PROMPT_TIMEOUT_MS,
dynamicHeight: true,
hideDismiss: true,
message: (
<FeedbackToast feature={feature} onDone={() => markFeatureFeedbackDone(feature)} />
),
});
}
function showFeedbackToastWhenReady(feature: FeedbackFeature) {
setTimeout(() => {
if (!jotaiStore.get(settingsAtom).promptFeedback) return;
if (jotaiStore.get(dialogsAtom).length === 0) {
showFeedbackToast(feature);
return;
}
const unsubscribe = jotaiStore.sub(dialogsAtom, () => {
if (jotaiStore.get(dialogsAtom).length > 0) return;
unsubscribe();
showFeedbackToast(feature);
});
}, FEEDBACK_PROMPT_DELAY_MS);
}
// Record a successful use of a feature, and prompt for feedback on the Nth use.
// Nothing is ever sent to the server from here; showing the toast is local-only
// and a submission only happens when the user clicks Send in it.
export function trackFeatureUsage(feature: FeedbackFeature) {
if (appInfo.featureLicense !== true || !jotaiStore.get(settingsAtom).promptFeedback) return;
const now = Date.now();
if (lastTrackedAt[feature] != null && now - lastTrackedAt[feature] < FEATURE_USE_DEBOUNCE_MS) {
return;
}
lastTrackedAt[feature] = now;
const state = getFeatureFeedbackState(feature);
if (state.done) return;
const uses = state.uses + 1;
const shouldPrompt = uses >= PROMPT_AFTER_USES && !promptedThisSession;
patchFeatureFeedbackState(feature, { uses });
if (!shouldPrompt) return;
promptedThisSession = true;
showFeedbackToastWhenReady(feature);
}
@@ -0,0 +1,8 @@
// Feature keys are sent to the server and used to group feedback for analysis.
// NEVER rename a key once it has shipped, or historical feedback will be split
// across the old and new names.
export const FEEDBACK_FEATURES = {
"git-sync": "How is Git sync working for you?",
} as const;
export type FeedbackFeature = keyof typeof FEEDBACK_FEATURES;
@@ -0,0 +1,37 @@
import { describe, expect, test } from "vite-plus/test";
import { getGraphQLOperationNames, parseGraphQLOperationNames } from "./graphqlOperationNames";
describe("getGraphQLOperationNames", () => {
test("returns named operations from a GraphQL document", () => {
expect(
getGraphQLOperationNames(`
query GetUser { user { id } }
mutation UpdateUser { updateUser { id } }
subscription UserChanged { userChanged { id } }
fragment UserFields on User { id }
`),
).toEqual(["GetUser", "UpdateUser", "UserChanged"]);
});
test("ignores anonymous operations", () => {
expect(getGraphQLOperationNames(`{ user { id } }`)).toEqual([]);
});
test("returns unique operation names in document order", () => {
expect(
getGraphQLOperationNames(`
query GetUser { user { id } }
query GetUser { user { name } }
query ListUsers { users { id } }
`),
).toEqual(["GetUser", "ListUsers"]);
});
test("returns no operations for invalid in-progress documents", () => {
expect(getGraphQLOperationNames(`query GetUser { user {`)).toEqual([]);
});
test("returns null when parsing invalid in-progress documents", () => {
expect(parseGraphQLOperationNames(`query GetUser { user {`)).toBeNull();
});
});
@@ -0,0 +1,26 @@
import { Kind, parse } from "graphql";
export function getGraphQLOperationNames(query: string): string[] {
return parseGraphQLOperationNames(query) ?? [];
}
export function parseGraphQLOperationNames(query: string): string[] | null {
try {
const names: string[] = [];
for (const definition of parse(query).definitions) {
if (definition.kind !== Kind.OPERATION_DEFINITION || definition.name == null) {
continue;
}
const name = definition.name.value;
if (!names.includes(name)) {
names.push(name);
}
}
return names;
} catch {
return null;
}
}
@@ -0,0 +1,152 @@
import { describe, expect, test } from "vite-plus/test";
import {
BODY_TYPE_BINARY,
BODY_TYPE_FORM_URLENCODED,
BODY_TYPE_GRAPHQL,
BODY_TYPE_JSON,
BODY_TYPE_NONE,
BODY_TYPE_OTHER,
BODY_TYPE_XML,
} from "./model_util";
import { convertRequestBody } from "./requestBodyConversion";
describe("convertRequestBody", () => {
test("converts imported JSON GraphQL bodies to GraphQL shape", () => {
const body = convertRequestBody({
fromBodyType: BODY_TYPE_JSON,
toBodyType: BODY_TYPE_GRAPHQL,
body: {
text: JSON.stringify({
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
variables: { id: "123" },
operationName: "GetUser",
}),
},
});
expect(body).toEqual({
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
variables: '{\n "id": "123"\n}',
operationName: "GetUser",
});
});
test("converts GraphQL bodies to JSON text", () => {
const body = convertRequestBody({
fromBodyType: BODY_TYPE_GRAPHQL,
toBodyType: BODY_TYPE_JSON,
body: {
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
variables: '{ "id": "123" }',
operationName: "GetUser",
},
});
expect(body).toEqual({
text: JSON.stringify(
{
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
variables: { id: "123" },
operationName: "GetUser",
},
null,
2,
),
});
});
test("converts urlencoded forms to urlencoded text for text-like bodies", () => {
const body = convertRequestBody({
fromBodyType: BODY_TYPE_FORM_URLENCODED,
toBodyType: BODY_TYPE_OTHER,
body: {
form: [
{ enabled: true, name: "basic", value: "aaa" },
{ enabled: true, name: "funky stuff", value: "*)%&#$)@ *$#)@&" },
{ enabled: false, name: "disabled", value: "hidden" },
{ enabled: true, name: "", value: "unnamed" },
],
},
});
expect(body).toEqual({
text: "basic=aaa&funky+stuff=*%29%25%26%23%24%29%40+*%24%23%29%40%26",
});
});
test("converts urlencoded forms to JSON text for JSON bodies", () => {
const body = convertRequestBody({
fromBodyType: BODY_TYPE_FORM_URLENCODED,
toBodyType: BODY_TYPE_JSON,
body: {
form: [
{ enabled: true, name: "tag", value: "one" },
{ enabled: true, name: "tag", value: "two" },
{ enabled: true, name: "limit", value: "10" },
],
},
});
expect(body).toEqual({
text: JSON.stringify({ tag: ["one", "two"], limit: "10" }, null, 2),
});
});
test("preserves text when converting to form bodies cannot build form pairs", () => {
const body = convertRequestBody({
fromBodyType: BODY_TYPE_XML,
toBodyType: BODY_TYPE_FORM_URLENCODED,
body: { text: "a=1&b=two+words" },
});
expect(body).toEqual({
text: "a=1&b=two+words",
});
});
test("preserves JSON text that is not a GraphQL envelope", () => {
const body = convertRequestBody({
fromBodyType: BODY_TYPE_JSON,
toBodyType: BODY_TYPE_GRAPHQL,
body: { text: JSON.stringify({ name: "Yaak" }) },
});
expect(body).toEqual({
text: JSON.stringify({ name: "Yaak" }),
});
});
test("preserves JSON arrays and primitives when converting to GraphQL", () => {
for (const text of [JSON.stringify([1, 2, 3]), JSON.stringify("query"), "123", "null"]) {
const body = convertRequestBody({
fromBodyType: BODY_TYPE_JSON,
toBodyType: BODY_TYPE_GRAPHQL,
body: { text },
});
expect(body).toEqual({ text });
}
});
test("preserves text when converting to binary cannot build a file body", () => {
const body = convertRequestBody({
fromBodyType: BODY_TYPE_JSON,
toBodyType: BODY_TYPE_BINARY,
body: { text: '{ "name": "Yaak" }' },
});
expect(body).toEqual({
text: '{ "name": "Yaak" }',
});
});
test("clears body when converting to no body", () => {
const body = convertRequestBody({
fromBodyType: BODY_TYPE_JSON,
toBodyType: BODY_TYPE_NONE,
body: { text: '{ "name": "Yaak" }' },
});
expect(body).toEqual({});
});
});
@@ -0,0 +1,199 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import {
BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART,
BODY_TYPE_FORM_URLENCODED,
BODY_TYPE_GRAPHQL,
BODY_TYPE_JSON,
BODY_TYPE_NONE,
} from "./model_util";
type Body = HttpRequest["body"];
type BodyType = HttpRequest["bodyType"];
type GraphQLBody = {
query: string;
variables: string | undefined;
operationName?: string;
};
export function convertRequestBody({
body,
fromBodyType,
toBodyType,
}: {
body: Body;
fromBodyType: BodyType;
toBodyType: BodyType;
}): Body {
if (toBodyType === BODY_TYPE_NONE) {
return {};
}
if (toBodyType === BODY_TYPE_GRAPHQL) {
return toGraphQLBody(body) ?? body;
}
if (toBodyType === BODY_TYPE_FORM_URLENCODED || toBodyType === BODY_TYPE_FORM_MULTIPART) {
return toFormBody(body) ?? body;
}
if (toBodyType === BODY_TYPE_BINARY) {
return typeof body.filePath === "string" ? { filePath: body.filePath } : body;
}
return toTextBody(body, fromBodyType, toBodyType) ?? body;
}
export function normalizeGraphQLBody(body: Body): GraphQLBody {
return toGraphQLBody(body) ?? { query: "", variables: undefined };
}
function toGraphQLBody(body: Body): GraphQLBody | null {
if (typeof body.query === "string") {
const result: GraphQLBody = {
query: body.query,
variables: typeof body.variables === "string" ? body.variables : undefined,
};
if (typeof body.operationName === "string") {
result.operationName = body.operationName;
}
return result;
}
if (typeof body.text === "string") {
try {
const parsed: unknown = JSON.parse(body.text);
if (!isRecord(parsed)) {
return null;
}
if (typeof parsed.query !== "string") {
return null;
}
const query = parsed.query;
const variables =
parsed.variables == null ? undefined : JSON.stringify(parsed.variables, null, 2);
const result: GraphQLBody = { query, variables };
if (typeof parsed.operationName === "string") {
result.operationName = parsed.operationName;
}
return result;
} catch {
return { query: body.text, variables: undefined };
}
}
return null;
}
function toFormBody(body: Body): Body | null {
if (Array.isArray(body.form)) {
return {
form: body.form.map((p) => ({
enabled: p.enabled !== false,
name: typeof p.name === "string" ? p.name : "",
value: stringifyFormValue(p.value ?? p.file),
contentType: typeof p.contentType === "string" ? p.contentType : undefined,
filename: typeof p.filename === "string" ? p.filename : undefined,
file: typeof p.file === "string" ? p.file : undefined,
id: typeof p.id === "string" ? p.id : undefined,
})),
};
}
return null;
}
function toTextBody(body: Body, fromBodyType: BodyType, toBodyType: BodyType): Body | null {
const sendJsonComments =
typeof body.sendJsonComments === "boolean" ? { sendJsonComments: body.sendJsonComments } : {};
if (typeof body.text === "string") {
return { text: body.text, ...sendJsonComments };
}
if (Array.isArray(body.form)) {
if (toBodyType === BODY_TYPE_JSON) {
return { text: JSON.stringify(formBodyToObject(body.form), null, 2) };
}
return { text: formBodyToUrlEncodedText(body.form) };
}
if (typeof body.query === "string") {
if (toBodyType === BODY_TYPE_JSON || fromBodyType === BODY_TYPE_GRAPHQL) {
const value: Record<string, unknown> = { query: body.query };
if (typeof body.variables === "string" && body.variables.trim() !== "") {
value.variables = parseJson(body.variables) ?? body.variables;
}
if (typeof body.operationName === "string" && body.operationName.trim() !== "") {
value.operationName = body.operationName;
}
return { text: JSON.stringify(value, null, 2) };
}
return { text: body.query };
}
if (typeof body.filePath === "string") {
return { text: body.filePath };
}
return null;
}
function formBodyToUrlEncodedText(form: unknown[]): string {
const params = new URLSearchParams();
for (const pair of form) {
if (!isRecord(pair)) continue;
if (pair.enabled === false) continue;
if (typeof pair.name !== "string" || pair.name === "") continue;
params.append(pair.name, stringifyFormValue(pair.value));
}
return params.toString();
}
function formBodyToObject(form: unknown[]) {
const result: Record<string, unknown> = {};
for (const pair of form) {
if (!isRecord(pair)) continue;
if (pair.enabled === false) continue;
if (typeof pair.name !== "string" || pair.name === "") continue;
const value = stringifyFormValue(pair.value);
if (pair.name in result) {
const existing = result[pair.name];
result[pair.name] = Array.isArray(existing) ? [...existing, value] : [existing, value];
} else {
result[pair.name] = value;
}
}
return result;
}
function stringifyFormValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
return JSON.stringify(value);
}
function parseJson(text: string): unknown | null {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
+1
View File
@@ -48,6 +48,7 @@ type TauriCmd =
| "cmd_save_response" | "cmd_save_response"
| "cmd_secure_template" | "cmd_secure_template"
| "cmd_send_ephemeral_request" | "cmd_send_ephemeral_request"
| "cmd_send_feedback"
| "cmd_send_http_request" | "cmd_send_http_request"
| "cmd_template_function_summaries" | "cmd_template_function_summaries"
| "cmd_template_function_config" | "cmd_template_function_config"
+5 -3
View File
@@ -28,15 +28,17 @@ export function showToast({
setTimeout(() => { setTimeout(() => {
const newToast: ToastInstance = { id, uniqueKey, timeout, ...props }; const newToast: ToastInstance = { id, uniqueKey, timeout, ...props };
if (timeout != null) {
setTimeout(() => hideToast(newToast), timeout);
}
jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]); jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]);
}, delay); }, delay);
return id; return id;
} }
export function hideToastById(id: string) {
const toast = jotaiStore.get(toastsAtom).find((t) => t.id === id);
if (toast) hideToast(toast);
}
export function hideToast(toHide: ToastInstance) { export function hideToast(toHide: ToastInstance) {
jotaiStore.set(toastsAtom, (all) => { jotaiStore.set(toastsAtom, (all) => {
const t = all.find((t) => t.uniqueKey === toHide.uniqueKey); const t = all.find((t) => t.uniqueKey === toHide.uniqueKey);
+3
View File
@@ -24,6 +24,9 @@ tauri-build = { version = "2.6.1", features = [] }
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work
[target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies]
rlimit = "0.11" # Raise the launchd 256 open-file soft limit at startup
[dependencies] [dependencies]
charset = "0.1.5" charset = "0.1.5"
chrono = { workspace = true, features = ["serde"] } chrono = { workspace = true, features = ["serde"] }
@@ -0,0 +1,67 @@
use log::{debug, warn};
use serde::Serialize;
use tauri::{AppHandle, Runtime, is_dev};
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_common::platform::get_os_str;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct FeedbackPayload {
feature: String,
text: String,
app_version: String,
os: String,
}
/// Send explicit user feedback for a feature. Fire-and-forget: errors are
/// logged and swallowed so a failed send never surfaces to the user.
pub async fn send_feedback<R: Runtime>(app_handle: &AppHandle<R>, feature: String, text: String) {
let app_version = app_handle.package_info().version.to_string();
let payload = FeedbackPayload {
feature,
text,
app_version: app_version.clone(),
os: get_os_str().to_string(),
};
let client = match yaak_api_client(ApiClientKind::App, &app_version) {
Ok(c) => c,
Err(e) => {
debug!("Failed to build feedback client: {e:?}");
return;
}
};
let url = build_url("/app-feedback");
debug!(
"Sending feature feedback to {url}: feature={}, app_version={}, os={}, text_len={}",
payload.feature,
payload.app_version,
payload.os,
payload.text.len()
);
match client.post(&url).json(&payload).send().await {
Ok(resp) => {
let status = resp.status();
if status.is_success() {
debug!("Sent feature feedback with status {status}");
} else {
let body = resp
.text()
.await
.unwrap_or_else(|e| format!("<failed to read response body: {e:?}>"));
warn!("Failed to send feature feedback with status {status}: {body}");
}
}
Err(e) => warn!("Failed to send feature feedback: {e:?}"),
}
}
fn build_url(path: &str) -> String {
if is_dev() {
format!("http://localhost:9444/api/v1{path}")
} else {
format!("https://api.yaak.app/api/v1{path}")
}
}
+20
View File
@@ -65,6 +65,7 @@ use yaak_tls::find_client_certificate;
mod commands; mod commands;
mod encoding; mod encoding;
mod error; mod error;
mod feedback;
mod git_ext; mod git_ext;
mod git_watcher; mod git_watcher;
mod grpc; mod grpc;
@@ -292,6 +293,16 @@ async fn cmd_render_template<R: Runtime>(
Ok(result) Ok(result)
} }
#[tauri::command]
async fn cmd_send_feedback<R: Runtime>(
app_handle: AppHandle<R>,
feature: String,
text: String,
) -> YaakResult<()> {
feedback::send_feedback(&app_handle, feature, text).await;
Ok(())
}
#[tauri::command] #[tauri::command]
async fn cmd_dismiss_notification<R: Runtime>( async fn cmd_dismiss_notification<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
@@ -1665,6 +1676,14 @@ async fn cmd_check_for_updates<R: Runtime>(
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
#[cfg_attr(feature = "cef", tauri::cef_entry_point)] #[cfg_attr(feature = "cef", tauri::cef_entry_point)]
pub fn run() { pub fn run() {
// GUI apps launched via Finder/launchd inherit a 256 open-file soft limit on macOS
// (1024 on most Linux desktops). SQLite WAL connections hold ~3 fds each, so raise
// the limit toward the hard cap before opening any DB pools.
#[cfg(any(target_os = "macos", target_os = "linux"))]
if let Err(e) = rlimit::increase_nofile_limit(10240) {
eprintln!("Failed to raise open-file limit: {e}");
}
let mut builder = tauri::Builder::<TauriRuntime>::default().plugin( let mut builder = tauri::Builder::<TauriRuntime>::default().plugin(
Builder::default() Builder::default()
.targets([ .targets([
@@ -1819,6 +1838,7 @@ pub fn run() {
cmd_delete_send_history, cmd_delete_send_history,
cmd_dismiss_notification, cmd_dismiss_notification,
cmd_export_data, cmd_export_data,
cmd_send_feedback,
cmd_http_request_body, cmd_http_request_body,
cmd_http_response_body, cmd_http_response_body,
cmd_format_json, cmd_format_json,
@@ -1,4 +1,7 @@
{ {
"build": {
"features": ["updater", "license", "wry"]
},
"app": { "app": {
"security": { "security": {
"capabilities": [ "capabilities": [
@@ -1,13 +1,18 @@
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
#[cfg(target_os = "linux")]
use std::time::Duration; use std::time::Duration;
#[cfg(target_os = "linux")]
use log::{debug, warn}; use log::{debug, warn};
use tauri::{AppHandle, Emitter, Runtime}; #[cfg(target_os = "linux")]
use tauri::Emitter;
use tauri::{AppHandle, Runtime};
pub const INITIAL_APPEARANCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__"; pub const INITIAL_APPEARANCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__";
pub const INITIAL_APPEARANCE_SOURCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE_SOURCE__"; pub const INITIAL_APPEARANCE_SOURCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE_SOURCE__";
pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change"; pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change";
#[cfg(target_os = "linux")]
const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1); const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -42,6 +47,8 @@ impl InitialAppearanceSource {
#[derive(Clone)] #[derive(Clone)]
pub struct SystemAppearanceState { pub struct SystemAppearanceState {
// Only read by the Linux polling thread
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
last_appearance: Arc<Mutex<Option<Appearance>>>, last_appearance: Arc<Mutex<Option<Appearance>>>,
} }
+77 -14
View File
@@ -191,12 +191,16 @@ fn build_url(r: &HttpRequest) -> String {
fn append_graphql_query_params(url: &str, body: &BTreeMap<String, serde_json::Value>) -> String { fn append_graphql_query_params(url: &str, body: &BTreeMap<String, serde_json::Value>) -> String {
let query = get_str_map(body, "query").to_string(); let query = get_str_map(body, "query").to_string();
let variables = strip_json_comments(&get_str_map(body, "variables")); let variables = strip_json_comments(&get_str_map(body, "variables"));
let operation_name = get_str_map(body, "operationName").to_string();
let mut params = vec![("query".to_string(), query)]; let mut params = vec![("query".to_string(), query)];
if !variables.trim().is_empty() { if !variables.trim().is_empty() {
params.push(("variables".to_string(), variables)); params.push(("variables".to_string(), variables));
} }
if !operation_name.trim().is_empty() {
params.push(("operationName".to_string(), operation_name));
}
// Strip existing query/variables params to avoid duplicates // Strip existing query/variables params to avoid duplicates
let url = strip_query_params(url, &["query", "variables"]); let url = strip_query_params(url, &["query", "variables", "operationName"]);
append_query_params(&url, params) append_query_params(&url, params)
} }
@@ -329,23 +333,30 @@ fn build_graphql_body(
) -> Option<SendableBodyWithMeta> { ) -> Option<SendableBodyWithMeta> {
let query = get_str_map(body, "query"); let query = get_str_map(body, "query");
let variables = strip_json_comments(&get_str_map(body, "variables")); let variables = strip_json_comments(&get_str_map(body, "variables"));
let operation_name = get_str_map(body, "operationName");
if method.to_lowercase() == "get" { if method.to_lowercase() == "get" {
// GraphQL GET requests use query parameters, not a body // GraphQL GET requests use query parameters, not a body
return None; return None;
} }
let body = if variables.trim().is_empty() { let mut body = serde_json::Map::new();
format!(r#"{{"query":{}}}"#, serde_json::to_string(&query).unwrap_or_default()) body.insert("query".to_string(), serde_json::Value::String(query.to_string()));
} else { if !variables.trim().is_empty() {
format!( body.insert(
r#"{{"query":{},"variables":{}}}"#, "variables".to_string(),
serde_json::to_string(&query).unwrap_or_default(), serde_json::from_str(&variables)
variables .unwrap_or_else(|_| serde_json::Value::String(variables)),
) );
}; }
if !operation_name.trim().is_empty() {
body.insert(
"operationName".to_string(),
serde_json::Value::String(operation_name.to_string()),
);
}
Some(SendableBodyWithMeta::Bytes(Bytes::from(body))) Some(SendableBodyWithMeta::Bytes(Bytes::from(serde_json::to_string(&body).unwrap_or_default())))
} }
async fn build_multipart_body( async fn build_multipart_body(
@@ -522,6 +533,33 @@ mod tests {
assert_eq!(result, "https://example.com/api?foo=bar&baz=qux"); assert_eq!(result, "https://example.com/api?foo=bar&baz=qux");
} }
#[test]
fn test_build_url_replaces_graphql_operation_name_from_body() {
let mut body = BTreeMap::new();
body.insert("query".to_string(), json!("query Foo { foo } query Bar { bar }"));
body.insert("operationName".to_string(), json!("Bar"));
let r = HttpRequest {
method: "GET".to_string(),
body_type: Some("graphql".to_string()),
body,
url: "https://example.com/graphql".to_string(),
url_parameters: vec![HttpUrlParameter {
enabled: true,
name: "operationName".to_string(),
value: "Foo".to_string(),
id: None,
}],
..Default::default()
};
let result = build_url(&r);
assert_eq!(
result,
"https://example.com/graphql?query=query%20Foo%20%7B%20foo%20%7D%20query%20Bar%20%7B%20bar%20%7D&operationName=Bar",
);
}
#[test] #[test]
fn test_build_url_with_disabled_params() { fn test_build_url_with_disabled_params() {
let r = HttpRequest { let r = HttpRequest {
@@ -880,9 +918,34 @@ mod tests {
let result = build_graphql_body("POST", &body); let result = build_graphql_body("POST", &body);
match result { match result {
Some(SendableBodyWithMeta::Bytes(bytes)) => { Some(SendableBodyWithMeta::Bytes(bytes)) => {
let expected = assert_eq!(
r#"{"query":"{ user(id: $id) { name } }","variables":{"id": "123"}}"#; serde_json::from_slice::<serde_json::Value>(&bytes).unwrap(),
assert_eq!(bytes, Bytes::from(expected)); json!({
"query": "{ user(id: $id) { name } }",
"variables": { "id": "123" },
}),
);
}
_ => panic!("Expected Some(SendableBody::Bytes)"),
}
}
#[tokio::test]
async fn test_graphql_body_with_operation_name() {
let mut body = BTreeMap::new();
body.insert("query".to_string(), json!("query Search { viewer { id } }"));
body.insert("operationName".to_string(), json!("Search"));
let result = build_graphql_body("POST", &body);
match result {
Some(SendableBodyWithMeta::Bytes(bytes)) => {
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&bytes).unwrap(),
json!({
"query": "query Search { viewer { id } }",
"operationName": "Search",
}),
);
} }
_ => panic!("Expected Some(SendableBody::Bytes)"), _ => panic!("Expected Some(SendableBody::Bytes)"),
} }
+1
View File
@@ -402,6 +402,7 @@ export type Settings = {
themeLight: string; themeLight: string;
updateChannel: string; updateChannel: string;
hideLicenseBadge: boolean; hideLicenseBadge: boolean;
promptFeedback: boolean;
autoupdate: boolean; autoupdate: boolean;
autoDownloadUpdates: boolean; autoDownloadUpdates: boolean;
checkNotifications: boolean; checkNotifications: boolean;
@@ -0,0 +1,3 @@
-- Add a setting to enable in-app feature feedback prompts
ALTER TABLE settings
ADD COLUMN prompt_feedback BOOLEAN DEFAULT TRUE NOT NULL;
+6 -9
View File
@@ -5,7 +5,6 @@ use log::{debug, info};
use r2d2::Pool; use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager; use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{OptionalExtension, params}; use rusqlite::{OptionalExtension, params};
use std::sync::{Arc, Mutex};
static BLOB_MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/blob_migrations"); static BLOB_MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/blob_migrations");
@@ -25,23 +24,21 @@ impl BodyChunk {
} }
/// Manages the blob database connection pool. /// Manages the blob database connection pool.
// Pool is internally synchronized — don't wrap it in a Mutex. A Mutex held across the
// blocking `get()` serializes every blob access behind the slowest waiter, freezing the
// whole app whenever the pool is exhausted.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct BlobManager { pub struct BlobManager {
pool: Arc<Mutex<Pool<SqliteConnectionManager>>>, pool: Pool<SqliteConnectionManager>,
} }
impl BlobManager { impl BlobManager {
pub fn new(pool: Pool<SqliteConnectionManager>) -> Self { pub fn new(pool: Pool<SqliteConnectionManager>) -> Self {
Self { pool: Arc::new(Mutex::new(pool)) } Self { pool }
} }
pub fn connect(&self) -> BlobContext { pub fn connect(&self) -> BlobContext {
let conn = self let conn = self.pool.get().expect("Failed to get blob DB connection from pool");
.pool
.lock()
.expect("Failed to gain lock on blob DB")
.get()
.expect("Failed to get blob DB connection from pool");
BlobContext { conn } BlobContext { conn }
} }
} }
+8 -3
View File
@@ -54,11 +54,15 @@ pub fn init_standalone(
create_dir_all(parent)?; create_dir_all(parent)?;
} }
// Main database pool // Main database pool. Sized for concurrent in-flight queries, not concurrent app
// features — connections are held per-statement, so even heavy fan-out (e.g. many
// gRPC streams) only needs a handful at once. Keep max_size modest: WAL connections
// hold ~3 file descriptors each, and macOS GUI apps get a 256 fd soft limit.
info!("Initializing app database {db_path:?}"); info!("Initializing app database {db_path:?}");
let manager = sqlite_file_manager(db_path); let manager = sqlite_file_manager(db_path);
let pool = Pool::builder() let pool = Pool::builder()
.max_size(100) .max_size(20)
.min_idle(Some(2))
.connection_timeout(Duration::from_secs(10)) .connection_timeout(Duration::from_secs(10))
.build(manager) .build(manager)
.map_err(|e| Error::Database(e.to_string()))?; .map_err(|e| Error::Database(e.to_string()))?;
@@ -70,7 +74,8 @@ pub fn init_standalone(
// Blob database pool // Blob database pool
let blob_manager = sqlite_file_manager(blob_path); let blob_manager = sqlite_file_manager(blob_path);
let blob_pool = Pool::builder() let blob_pool = Pool::builder()
.max_size(50) .max_size(10)
.min_idle(Some(1))
.connection_timeout(Duration::from_secs(10)) .connection_timeout(Duration::from_secs(10))
.build(blob_manager) .build(blob_manager)
.map_err(|e| Error::Database(e.to_string()))?; .map_err(|e| Error::Database(e.to_string()))?;
+4
View File
@@ -246,6 +246,7 @@ pub struct Settings {
pub theme_light: String, pub theme_light: String,
pub update_channel: String, pub update_channel: String,
pub hide_license_badge: bool, pub hide_license_badge: bool,
pub prompt_feedback: bool,
pub autoupdate: bool, pub autoupdate: bool,
pub auto_download_updates: bool, pub auto_download_updates: bool,
pub check_notifications: bool, pub check_notifications: bool,
@@ -303,6 +304,7 @@ impl UpsertModelInfo for Settings {
(ThemeLight, self.theme_light.as_str().into()), (ThemeLight, self.theme_light.as_str().into()),
(UpdateChannel, self.update_channel.into()), (UpdateChannel, self.update_channel.into()),
(HideLicenseBadge, self.hide_license_badge.into()), (HideLicenseBadge, self.hide_license_badge.into()),
(PromptFeedback, self.prompt_feedback.into()),
(Autoupdate, self.autoupdate.into()), (Autoupdate, self.autoupdate.into()),
(AutoDownloadUpdates, self.auto_download_updates.into()), (AutoDownloadUpdates, self.auto_download_updates.into()),
(ColoredMethods, self.colored_methods.into()), (ColoredMethods, self.colored_methods.into()),
@@ -332,6 +334,7 @@ impl UpsertModelInfo for Settings {
SettingsIden::ThemeLight, SettingsIden::ThemeLight,
SettingsIden::UpdateChannel, SettingsIden::UpdateChannel,
SettingsIden::HideLicenseBadge, SettingsIden::HideLicenseBadge,
SettingsIden::PromptFeedback,
SettingsIden::Autoupdate, SettingsIden::Autoupdate,
SettingsIden::AutoDownloadUpdates, SettingsIden::AutoDownloadUpdates,
SettingsIden::ColoredMethods, SettingsIden::ColoredMethods,
@@ -372,6 +375,7 @@ impl UpsertModelInfo for Settings {
autoupdate: row.get("autoupdate")?, autoupdate: row.get("autoupdate")?,
auto_download_updates: row.get("auto_download_updates")?, auto_download_updates: row.get("auto_download_updates")?,
hide_license_badge: row.get("hide_license_badge")?, hide_license_badge: row.get("hide_license_badge")?,
prompt_feedback: row.get("prompt_feedback")?,
colored_methods: row.get("colored_methods")?, colored_methods: row.get("colored_methods")?,
check_notifications: row.get("check_notifications")?, check_notifications: row.get("check_notifications")?,
hotkeys: serde_json::from_str(&hotkeys).unwrap_or_default(), hotkeys: serde_json::from_str(&hotkeys).unwrap_or_default(),
@@ -38,6 +38,7 @@ impl<'a> ClientDb<'a> {
autoupdate: true, autoupdate: true,
colored_methods: false, colored_methods: false,
hide_license_badge: false, hide_license_badge: false,
prompt_feedback: true,
auto_download_updates: true, auto_download_updates: true,
check_notifications: true, check_notifications: true,
hotkeys: HashMap::new(), hotkeys: HashMap::new(),
+9 -21
View File
@@ -4,27 +4,25 @@ use crate::util::ModelPayload;
use r2d2::Pool; use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager; use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::TransactionBehavior; use rusqlite::TransactionBehavior;
use std::sync::{Arc, Mutex, mpsc}; use std::sync::mpsc;
use yaak_database::{ConnectionOrTx, DbContext}; use yaak_database::{ConnectionOrTx, DbContext};
// Pool is internally synchronized — don't wrap it in a Mutex. A Mutex held across the
// blocking `get()` serializes every DB access behind the slowest waiter, freezing the
// whole app whenever the pool is exhausted.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct QueryManager { pub struct QueryManager {
pool: Arc<Mutex<Pool<SqliteConnectionManager>>>, pool: Pool<SqliteConnectionManager>,
events_tx: mpsc::Sender<ModelPayload>, events_tx: mpsc::Sender<ModelPayload>,
} }
impl QueryManager { impl QueryManager {
pub fn new(pool: Pool<SqliteConnectionManager>, events_tx: mpsc::Sender<ModelPayload>) -> Self { pub fn new(pool: Pool<SqliteConnectionManager>, events_tx: mpsc::Sender<ModelPayload>) -> Self {
QueryManager { pool: Arc::new(Mutex::new(pool)), events_tx } QueryManager { pool, events_tx }
} }
pub fn connect(&self) -> ClientDb<'_> { pub fn connect(&self) -> ClientDb<'_> {
let conn = self let conn = self.pool.get().expect("Failed to get a new DB connection from the pool");
.pool
.lock()
.expect("Failed to gain lock on DB")
.get()
.expect("Failed to get a new DB connection from the pool");
let ctx = DbContext::new(ConnectionOrTx::Connection(conn)); let ctx = DbContext::new(ConnectionOrTx::Connection(conn));
ClientDb::new(ctx, self.events_tx.clone()) ClientDb::new(ctx, self.events_tx.clone())
} }
@@ -33,12 +31,7 @@ impl QueryManager {
where where
F: FnOnce(&ClientDb) -> T, F: FnOnce(&ClientDb) -> T,
{ {
let conn = self let conn = self.pool.get().expect("Failed to get new DB connection from the pool");
.pool
.lock()
.expect("Failed to gain lock on DB for transaction")
.get()
.expect("Failed to get new DB connection from the pool");
let ctx = DbContext::new(ConnectionOrTx::Connection(conn)); let ctx = DbContext::new(ConnectionOrTx::Connection(conn));
let db = ClientDb::new(ctx, self.events_tx.clone()); let db = ClientDb::new(ctx, self.events_tx.clone());
@@ -53,12 +46,7 @@ impl QueryManager {
where where
E: From<crate::error::Error>, E: From<crate::error::Error>,
{ {
let mut conn = self let mut conn = self.pool.get().expect("Failed to get new DB connection from the pool");
.pool
.lock()
.expect("Failed to gain lock on DB for transaction")
.get()
.expect("Failed to get new DB connection from the pool");
let tx = conn let tx = conn
.transaction_with_behavior(TransactionBehavior::Immediate) .transaction_with_behavior(TransactionBehavior::Immediate)
.expect("Failed to start DB transaction"); .expect("Failed to start DB transaction");
+57 -15
View File
@@ -511,7 +511,10 @@ pub async fn send_http_request<T: TemplateCallback>(
.map_err(SendHttpRequestError::PrepareSendableRequest)?; .map_err(SendHttpRequestError::PrepareSendableRequest)?;
} }
let request_content_length = sendable_body_length(sendable_request.body.as_ref()); let request_content_length = match sendable_request.body.as_ref() {
Some(SendableBody::Bytes(_)) => sendable_body_length(sendable_request.body.as_ref()),
Some(SendableBody::Stream { .. }) | None => None,
};
let mut response = params.existing_response.unwrap_or_default(); let mut response = params.existing_response.unwrap_or_default();
response.request_id = params.request.id.clone(); response.request_id = params.request.id.clone();
response.workspace_id = params.request.workspace_id.clone(); response.workspace_id = params.request.workspace_id.clone();
@@ -811,16 +814,6 @@ pub async fn send_http_request<T: TemplateCallback>(
})?; })?;
drop(body_stream); drop(body_stream);
if let Some(task) = request_body_capture_task.take() {
match task.await {
Ok(Ok(total)) => {
response.request_content_length = Some(usize_to_i32(total));
}
Ok(Err(err)) => request_body_capture_error = Some(err),
Err(err) => request_body_capture_error = Some(err.to_string()),
}
}
if let Some(err) = request_body_capture_error.take() { if let Some(err) = request_body_capture_error.take() {
response.error = Some(append_error_message( response.error = Some(append_error_message(
response.error.take(), response.error.take(),
@@ -828,10 +821,6 @@ pub async fn send_http_request<T: TemplateCallback>(
)); ));
} }
if let Err(join_err) = event_handle.await {
warn!("Failed to join response event task: {}", join_err);
}
if let Some(err) = body_read_error { if let Some(err) = body_read_error {
if persist_response { if persist_response {
let _ = persist_response_error( let _ = persist_response_error(
@@ -849,6 +838,16 @@ pub async fn send_http_request<T: TemplateCallback>(
cookie_jar.as_mut(), cookie_jar.as_mut(),
cookie_behavior.store.as_ref(), cookie_behavior.store.as_ref(),
)?; )?;
if let Some(task) = request_body_capture_task.take() {
match task.await {
Ok(Ok(_)) => {}
Ok(Err(err)) => warn!("Failed to store request body after response error: {err}"),
Err(err) => warn!("Failed to join request body capture task: {err}"),
}
}
if let Err(join_err) = event_handle.await {
warn!("Failed to join response event task: {}", join_err);
}
return Err(err); return Err(err);
} }
@@ -875,6 +874,49 @@ pub async fn send_http_request<T: TemplateCallback>(
persist_cookie_jar(params.query_manager, cookie_jar.as_mut(), cookie_behavior.store.as_ref())?; persist_cookie_jar(params.query_manager, cookie_jar.as_mut(), cookie_behavior.store.as_ref())?;
// Request-body history can be much larger than the response. It should not keep the
// response in a loading state after the network/response-body work has completed.
if let Some(task) = request_body_capture_task.take() {
let mut update_response = false;
match task.await {
Ok(Ok(total)) => {
let total = Some(usize_to_i32(total));
if response.request_content_length != total {
response.request_content_length = total;
update_response = true;
}
}
Ok(Err(err)) => {
response.error = Some(append_error_message(
response.error.take(),
format!("Request succeeded but failed to store request body: {err}"),
));
update_response = true;
}
Err(err) => {
response.error = Some(append_error_message(
response.error.take(),
format!("Request succeeded but failed to store request body: {err}"),
));
update_response = true;
}
}
if update_response && persist_response {
response = params
.query_manager
.connect()
.upsert_http_response(&response, &params.update_source, params.blob_manager)
.map_err(SendHttpRequestError::PersistResponse)?;
}
}
// Timeline events are useful history, but they should not keep the response in a loading state
// after the network/response-body work has completed.
if let Err(join_err) = event_handle.await {
warn!("Failed to join response event task: {}", join_err);
}
Ok(SendHttpRequestResult { rendered_request, response, response_body }) Ok(SendHttpRequestResult { rendered_request, response, response_body })
} }
+1 -1
View File
@@ -83,7 +83,7 @@
"@tauri-apps/cli": "npm:@tauri-apps/cli-cef@3.0.0-alpha.6", "@tauri-apps/cli": "npm:@tauri-apps/cli-cef@3.0.0-alpha.6",
"@types/babel__core": "^7.20.5", "@types/babel__core": "^7.20.5",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"@yaakapp/cli": "^0.5.1", "@yaakapp/cli": "latest",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"nodejs-file-downloader": "^4.13.0", "nodejs-file-downloader": "^4.13.0",
+1 -1
View File
@@ -116,7 +116,7 @@
"@tauri-apps/cli": "npm:@tauri-apps/cli-cef@3.0.0-alpha.6", "@tauri-apps/cli": "npm:@tauri-apps/cli-cef@3.0.0-alpha.6",
"@types/babel__core": "^7.20.5", "@types/babel__core": "^7.20.5",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"@yaakapp/cli": "^0.5.1", "@yaakapp/cli": "latest",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"nodejs-file-downloader": "^4.13.0", "nodejs-file-downloader": "^4.13.0",
+1
View File
@@ -93,6 +93,7 @@ export async function convertToCurl(request: Partial<HttpRequest>) {
const body = { const body = {
query: request.body.query || "", query: request.body.query || "",
variables: maybeParseJSON(request.body.variables, undefined), variables: maybeParseJSON(request.body.variables, undefined),
operationName: request.body.operationName || undefined,
}; };
xs.push("--data", quote(JSON.stringify(body))); xs.push("--data", quote(JSON.stringify(body)));
xs.push(NEWLINE); xs.push(NEWLINE);
@@ -66,6 +66,25 @@ describe("exporter-curl", () => {
); );
}); });
test("Exports POST with GraphQL operation name", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
method: "POST",
bodyType: "graphql",
body: {
query: "query Foo { foo } query Bar { bar }",
operationName: "Foo",
},
}),
).toEqual(
[
`curl -X POST 'https://yaak.app'`,
`--data '{"query":"query Foo { foo } query Bar { bar }","operationName":"Foo"}'`,
].join(" \\\n "),
);
});
test("Exports POST with GraphQL data no variables", async () => { test("Exports POST with GraphQL data no variables", async () => {
expect( expect(
await convertToCurl({ await convertToCurl({
+117
View File
@@ -0,0 +1,117 @@
type GraphQLDetectionSignal = {
score: number;
requiresGraphQLDocument?: boolean;
};
export type GraphQLJsonBody = {
query: string;
variables?: string;
operationName?: string;
};
type GraphQLJsonBodyArgs = {
mimeType: string | null;
text: string;
url: string;
};
export function isGraphQLJsonBody(args: GraphQLJsonBodyArgs): boolean {
return parseGraphQLJsonBody(args) != null;
}
export function parseGraphQLJsonBody({
mimeType,
text,
url,
}: GraphQLJsonBodyArgs): GraphQLJsonBody | null {
if (mimeType !== "application/json") {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const body = parsed as Record<string, unknown>;
if (typeof body.query !== "string") {
return null;
}
if (hasExtraGraphQLEnvelopeFields(body)) {
return null;
}
const signals = getGraphQLDetectionSignals(body, url);
const score = signals.reduce((total, signal) => total + signal.score, 0);
const hasGraphQLDocument = signals.some((signal) => signal.requiresGraphQLDocument);
if (!hasGraphQLDocument || score < 4) {
return null;
}
const result: GraphQLJsonBody = { query: body.query };
if (body.variables != null) {
result.variables =
typeof body.variables === "string" ? body.variables : JSON.stringify(body.variables, null, 2);
}
if (typeof body.operationName === "string") {
result.operationName = body.operationName;
}
return result;
}
function hasExtraGraphQLEnvelopeFields(body: Record<string, unknown>): boolean {
const allowedKeys = new Set(["query", "variables", "operationName"]);
return Object.keys(body).some((key) => !allowedKeys.has(key));
}
function getGraphQLDetectionSignals(
body: Record<string, unknown>,
url: string,
): GraphQLDetectionSignal[] {
const signals: GraphQLDetectionSignal[] = [];
const query = body.query as string;
const urlPath = getUrlPath(url).toLowerCase();
if (/\b(graphql|gql)\b/.test(urlPath)) {
signals.push({ score: 2 });
}
if (/^(query|mutation|subscription|fragment)\b/.test(query.trim())) {
signals.push({ score: 3 });
} else if (/^\{[\s\S]*\}$/.test(query.trim())) {
signals.push({ score: 3, requiresGraphQLDocument: true });
}
if (/\{[\s\S]*\}/.test(query)) {
signals.push({ score: 1, requiresGraphQLDocument: true });
}
if (typeof body.operationName === "string" && body.operationName.trim() !== "") {
signals.push({ score: 1 });
}
if (
body.variables != null &&
(typeof body.variables === "object" || typeof body.variables === "string")
) {
signals.push({ score: 1 });
}
return signals;
}
function getUrlPath(url: string): string {
try {
return new URL(url).pathname;
} catch {
return url;
}
}
+20 -11
View File
@@ -8,6 +8,7 @@ import type {
Workspace, Workspace,
} from "@yaakapp/api"; } from "@yaakapp/api";
import { split } from "shlex"; import { split } from "shlex";
import { parseGraphQLJsonBody } from "./graphql";
type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>; type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
@@ -464,6 +465,8 @@ function importCommand(parseEntries: string[], workspaceId: string) {
let body = {}; let body = {};
let bodyType: string | null = null; let bodyType: string | null = null;
const bodyAsGET = getPairValue(flagsByName, false, ["G", "get"]); const bodyAsGET = getPairValue(flagsByName, false, ["G", "get"]);
const hasDataBody = dataParameters.length > 0 && !bodyAsGET;
const hasFormBody = multipartFormDataFromRaw != null || formDataParams.length > 0;
if (multipartFormDataFromRaw) { if (multipartFormDataFromRaw) {
// Handle multipart form data parsed from --data-raw (Chrome DevTools format) // Handle multipart form data parsed from --data-raw (Chrome DevTools format)
@@ -491,15 +494,21 @@ function importCommand(parseEntries: string[], workspaceId: string) {
enabled: true, enabled: true,
}); });
} else if (dataParameters.length > 0) { } else if (dataParameters.length > 0) {
bodyType = const text = dataParameters
mimeType === "application/json" || mimeType === "text/xml" || mimeType === "text/plain" .map(({ name, value }) => (name && value ? `${name}=${value}` : name || value))
? mimeType .join("&");
: "other"; const graphqlBody = parseGraphQLJsonBody({ mimeType, text, url });
body = {
text: dataParameters if (graphqlBody != null) {
.map(({ name, value }) => (name && value ? `${name}=${value}` : name || value)) bodyType = "graphql";
.join("&"), body = graphqlBody;
}; } else if (mimeType === "application/json" || mimeType === "text/xml" || mimeType === "text/plain") {
bodyType = mimeType;
body = { text };
} else {
bodyType = "other";
body = { text };
}
} else if (formDataParams.length) { } else if (formDataParams.length) {
bodyType = mimeType ?? "multipart/form-data"; bodyType = mimeType ?? "multipart/form-data";
body = { body = {
@@ -517,8 +526,8 @@ function importCommand(parseEntries: string[], workspaceId: string) {
// Method // Method
let method = getPairValue(flagsByName, "", ["X", "request"]).toUpperCase(); let method = getPairValue(flagsByName, "", ["X", "request"]).toUpperCase();
if (method === "" && body) { if (method === "") {
method = "text" in body || "form" in body ? "POST" : "GET"; method = hasDataBody || hasFormBody ? "POST" : "GET";
} }
const request: ExportResources["httpRequests"][0] = { const request: ExportResources["httpRequests"][0] = {
+146
View File
@@ -0,0 +1,146 @@
import { describe, expect, test } from "vite-plus/test";
import { isGraphQLJsonBody, parseGraphQLJsonBody } from "../src/graphql";
describe("isGraphQLJsonBody", () => {
test("detects named query documents without a GraphQL URL", () => {
const args = {
mimeType: "application/json",
text: JSON.stringify({
query: "query Search($id: ID!) { node(id: $id) { id } }",
variables: { id: "123" },
operationName: "Search",
}),
url: "https://api.example.com/search",
};
expect(isGraphQLJsonBody(args)).toBe(true);
expect(parseGraphQLJsonBody(args)).toEqual({
query: "query Search($id: ID!) { node(id: $id) { id } }",
variables: '{\n "id": "123"\n}',
operationName: "Search",
});
});
test("detects mutation documents", () => {
expect(
isGraphQLJsonBody({
mimeType: "application/json",
text: JSON.stringify({ query: "mutation Save { saveThing { id } }" }),
url: "https://api.example.com",
}),
).toBe(true);
});
test("detects anonymous selection set documents", () => {
expect(
isGraphQLJsonBody({
mimeType: "application/json",
text: JSON.stringify({ query: "{ viewer { id email } }" }),
url: "https://api.example.com",
}),
).toBe(true);
});
test("detects document bodies on GraphQL-looking paths", () => {
expect(
isGraphQLJsonBody({
mimeType: "application/json",
text: JSON.stringify({ query: "query Search { viewer { id } }", operationName: "Search" }),
url: "https://api.example.com/v1/graphql",
}),
).toBe(true);
});
test("does not detect incomplete operation documents even on GraphQL-looking paths", () => {
expect(
isGraphQLJsonBody({
mimeType: "application/json",
text: JSON.stringify({ query: "query Search", operationName: "Search" }),
url: "https://api.example.com/graphql",
}),
).toBe(false);
});
test("does not detect plain JSON query fields even on GraphQL-looking paths", () => {
expect(
isGraphQLJsonBody({
mimeType: "application/json",
text: JSON.stringify({ query: "SearchQueryInput!" }),
url: "https://api.example.com/graphql",
}),
).toBe(false);
});
test("does not use variables and operationName alone as enough evidence", () => {
expect(
isGraphQLJsonBody({
mimeType: "application/json",
text: JSON.stringify({
query: "SearchQueryInput!",
variables: { id: "123" },
operationName: "Search",
}),
url: "https://api.example.com",
}),
).toBe(false);
});
test("detects bodies with string variables without parsing them", () => {
const args = {
mimeType: "application/json",
text: JSON.stringify({
query: "query Search($id: ID!) { node(id: $id) { id } }",
variables: '{ "id": "123" }',
}),
url: "https://api.example.com",
};
expect(isGraphQLJsonBody(args)).toBe(true);
expect(parseGraphQLJsonBody(args)).toEqual({
query: "query Search($id: ID!) { node(id: $id) { id } }",
variables: '{ "id": "123" }',
});
});
test("does not detect GraphQL envelopes with extra fields", () => {
const args = {
mimeType: "application/json",
text: JSON.stringify({
query: "query Search($id: ID!) { node(id: $id) { id } }",
variables: { id: "123" },
extensions: { persistedQuery: { version: 1, sha256Hash: "abc123" } },
}),
url: "https://api.example.com/graphql",
};
expect(isGraphQLJsonBody(args)).toBe(false);
expect(parseGraphQLJsonBody(args)).toBeNull();
});
test("ignores invalid JSON and non-object JSON", () => {
expect(
isGraphQLJsonBody({
mimeType: "application/json",
text: "not json",
url: "https://api.example.com/graphql",
}),
).toBe(false);
expect(
isGraphQLJsonBody({
mimeType: "application/json",
text: "[]",
url: "https://api.example.com/graphql",
}),
).toBe(false);
});
test("ignores non-JSON MIME types", () => {
expect(
isGraphQLJsonBody({
mimeType: "text/plain",
text: JSON.stringify({ query: "query Search { viewer { id } }" }),
url: "https://api.example.com/graphql",
}),
).toBe(false);
});
});
+47
View File
@@ -562,6 +562,53 @@ describe("importer-curl", () => {
}); });
}); });
test("Imports GraphQL JSON data as a GraphQL request", () => {
expect(
convertCurl(
`curl 'https://yaak.app/graphql' -H 'Content-Type: application/json' --data-raw $'{"query":"query Search($id: ID\\u0021) { node(id: $id) { id } }","variables":{"id":"123"}}'`,
),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: "https://yaak.app/graphql",
method: "POST",
headers: [{ name: "Content-Type", value: "application/json", enabled: true }],
bodyType: "graphql",
body: {
query: "query Search($id: ID!) { node(id: $id) { id } }",
variables: '{\n "id": "123"\n}',
},
}),
],
},
});
});
test("Imports GraphQL JSON with extensions as JSON", () => {
expect(
convertCurl(
`curl 'https://yaak.app/graphql' -H 'Content-Type: application/json' --data-raw $'{"query":"query Search($id: ID\\u0021) { node(id: $id) { id } }","extensions":{"persistedQuery":{"version":1,"sha256Hash":"abc123"}}}'`,
),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: "https://yaak.app/graphql",
method: "POST",
headers: [{ name: "Content-Type", value: "application/json", enabled: true }],
bodyType: "application/json",
body: {
text: '{"query":"query Search($id: ID!) { node(id: $id) { id } }","extensions":{"persistedQuery":{"version":1,"sha256Hash":"abc123"}}}',
},
}),
],
},
});
});
test("Imports data with multiple escape sequences", () => { test("Imports data with multiple escape sequences", () => {
expect( expect(
convertCurl( convertCurl(