Compare commits

..

21 Commits

Author SHA1 Message Date
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 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
Gregory Schier 44ae19ff64 Bundle NSS modules in CEF AppImages 2026-07-03 16:23:24 -07:00
Gregory Schier 23bac3cff5 Build CEF Linux as experimental AppImage 2026-07-03 15:15:04 -07:00
Gregory Schier cc05fec59d Add CEF runtime to Linux builds (#494) 2026-07-03 14:22:47 -07:00
Gregory Schier 1206d5889d Fix settings banner width 2026-07-03 14:21:31 -07:00
Gregory Schier 273e9c184d Improve SQLite response persistence performance (#496) 2026-07-03 14:05:02 -07:00
Gregory Schier 3f9baca85e Respect manual contribution policy labels 2026-07-03 10:52:44 -07:00
Gregory Schier 0497a54928 Custom Tauri window state plugin (#495) 2026-07-03 10:11:20 -07:00
Gregory Schier 5db2008fae Add Yaak changelog skill 2026-07-02 10:17:53 -07:00
70 changed files with 3447 additions and 599 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".
+104
View File
@@ -0,0 +1,104 @@
---
name: yaak-changelog
description: Create or edit Yaak changelogs. Beta and draft prerelease changelogs live only in the GitHub release body; stable release changelogs live in `src/content/changelog/YYYYMMDD_VERSION/`. Use when Codex needs to update a beta GitHub release body, generate a stable website changelog from beta releases, update `_release.yaml` or `_intro.md`, expand major entries into markdown files, or preserve Yaak's changelog writing style.
---
# Yaak Changelog
Use this skill to create Yaak changelogs in the correct place:
- Beta or draft prerelease changelogs live only on the GitHub release.
- Stable release changelogs live in website files under `src/content/changelog/YYYYMMDD_VERSION/`.
## Workflow
1. Identify the target release.
- If the target tag contains `-beta` or the GitHub release is a draft prerelease, update the GitHub release body only. Do not create or edit `src/content/changelog/` files for beta releases.
- For a new stable changelog, gather all beta release notes for that version since the previous stable release, then create or edit website changelog files.
- Prefer `gh api` or GitHub release pages. If network access is restricted, request permission before querying GitHub.
- Extract PR numbers and `yaak.app/feedback` URLs while fetching. If most bullets do not include PR references, fetch again with a more specific prompt.
- Fetch PR authors when generating or revising release notes. Include contributor attribution for non-`@gschier` PR authors.
2. Parse release bullets.
- Treat each release-note bullet as one changelog entry.
- Skip dependency-only, generated, build-only, test-only, CI-only, and internal maintenance bullets unless they have a clear user-facing impact that can be described in user terms.
- For stable website changelogs, skip bullets prefixed with `[beta-only]`.
- Preserve the entry wording closely. Remove wrapping quotes from titles.
- Map categories to `feature`, `fix`, `improvement`, or `breaking`.
- Convert `#NNN` into `https://github.com/mountain-loop/yaak/pull/NNN`.
3. For beta or draft prerelease changelogs, update the GitHub release.
- Keep the changelog in the GitHub release body. Do not create a website changelog directory.
- Do not add a changelog badge or link to `yaak.app/changelog/VERSION` for beta releases.
- Prefer concise bullets with PR links and feedback links when available.
- When a bullet has a feedback URL, wrap the changelog item text itself in the feedback link, then put the PR link after it. Example: `- [Fixed request history timestamps](https://yaak.app/feedback/posts/request-history-time-stamp) in [#492](https://github.com/mountain-loop/yaak/pull/492)`.
- Append `by [@handle](https://github.com/handle)` to PR-backed bullets authored by external contributors. Do not append `by @gschier` for `@gschier` PRs.
- Include a `**Full Changelog**` comparison link using the previous beta tag when it exists, or the previous stable tag for `beta.1`.
- Use `gh release edit TAG --repo mountain-loop/yaak --notes-file ...` or the GitHub release API to update the draft/prerelease body.
- Stop after verifying the GitHub release body. The website checks below do not apply.
4. For stable website changelogs, create or edit the release directory.
- Path format: `src/content/changelog/YYYYMMDD_VERSION/`.
- For a new release, use today's date for `YYYYMMDD`.
- For an existing release, keep the original directory date.
- Do not create changelog directories for beta releases.
5. Write `_release.yaml`.
- Include `draft`, optional `title`, `summary`, `image`, `youtube`, and `entries`.
- Keep minor items as quick entries without `content`.
- Use `content` only when an entry needs its own markdown section.
```yaml
title: "What's New in 2026.1.0"
summary: "Brief overview of the most important additions and fixes"
draft: true
entries:
- title: "Request debugging"
category: feature
pr: "https://github.com/mountain-loop/yaak/pull/123"
feedback: "https://feedback.yaak.app/p/request-debugging"
content: "request-debugging.md"
- title: "Fix broken cookie clearing"
category: fix
pr: "https://github.com/mountain-loop/yaak/pull/124"
```
6. Expand major entries.
- Expand 3 to 6 major items when enough context exists.
- Create slugified markdown files and reference them with `content`.
- Read the related PR before writing expanded content.
- Add emoji prefixes only for expanded entry titles if it helps distinguish major sections.
7. Handle images.
- Reuse screenshots from PRs when they exist.
- Convert GitHub private attachment URLs to `https://github.com/user-attachments/assets/UUID` before upload.
- Upload with `go run cmd/yaakadmin/main.go upload "URL"` when the environment permits it.
- If no real image is available, use a placeholder with real alt text and a caption.
8. Write `_intro.md`.
- Add a short overview paragraph at the top of the release.
- Focus on the major themes across the release instead of repeating every bullet.
9. Follow Yaak writing style.
- Be direct and factual. Avoid hype.
- State what changed and how to use it.
- Keep paragraphs short.
- Use backticks for code symbols, settings, and literal values.
- Use bold sparingly for the most important phrase in a section.
## File Rules
- Beta releases must not create or edit files in `src/content/changelog/`.
- Main files are `_release.yaml` and optional `_intro.md`.
- Expanded entry files are regular markdown files such as `request-debugging.md`.
- `entries[].content` must match an existing markdown filename in the same directory.
- Images for changelog pages live under `static/changelog/VERSION/` when committed to the repo.
## Checks
- For beta releases, verify `gh release view TAG --repo mountain-loop/yaak --json body,tagName,isDraft,isPrerelease` and ensure no website changelog files were created.
- For beta releases, verify feedback-backed bullets use the feedback URL as the link target for the whole item text, not as a separate trailing `Feedback:` link.
- For stable releases, ensure each user-facing source bullet becomes exactly one changelog entry unless it is `[beta-only]` or dependency-only/internal maintenance.
- Ensure most entries include `pr` when the source release notes provide one.
- For stable releases, ensure every referenced `content` file exists.
- If the user wants stable website verification, run the site and inspect `/changelog/VERSION` and `/rss.xml`.
@@ -0,0 +1,7 @@
interface:
display_name: "Yaak Changelog"
short_description: "Generate Yaak changelog releases"
default_prompt: "Use $yaak-changelog to create or update a Yaak changelog release from GitHub release notes."
policy:
allow_implicit_invocation: true
+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):
+42 -25
View File
@@ -10,8 +10,7 @@ const REVIEWER_LOGIN = "gschier";
const LARGE_DIFF_CHANGED_FILES = 20; const LARGE_DIFF_CHANGED_FILES = 20;
const LARGE_DIFF_CHANGED_LINES = 800; const LARGE_DIFF_CHANGED_LINES = 800;
const SUMMARY_TITLE_MAX_LENGTH = 80; const SUMMARY_TITLE_MAX_LENGTH = 80;
const AUTOMATIC_PR_CREATED_AFTER = "2026-06-30T07:00:00.000Z"; const MIN_AUTOMATIC_PR_NUMBER = 494;
const AUTOMATIC_PR_CREATED_AFTER_LABEL = "June 30, 2026";
const LABELS = { const LABELS = {
inScope: { inScope: {
@@ -54,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) {
@@ -103,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);
@@ -113,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";
} }
} }
@@ -186,6 +194,18 @@ function analyzePullRequest(pr) {
}; };
} }
if (labelNames.has(LABELS.explicitPermission.name)) {
return {
blockers: [],
changedFiles,
desiredLabels: [LABELS.explicitPermission.name],
largeDiff,
status: "in_scope",
templateUsed,
totalChangedLines,
};
}
if (!templateUsed) { if (!templateUsed) {
blockers.push({ blockers.push({
label: LABELS.missingTemplate.name, label: LABELS.missingTemplate.name,
@@ -244,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.",
}); });
} }
@@ -252,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.",
}); });
} }
} }
@@ -428,10 +449,6 @@ function summarizeResult({ pr, analysis, skipped, skipReason }) {
}; };
} }
function wasCreatedBefore(value, cutoff) {
return Date.parse(value) < Date.parse(cutoff);
}
async function isOfficialMaintainer({ github, owner, repo, pr }) { async function isOfficialMaintainer({ github, owner, repo, pr }) {
if (MAINTAINER_LOGINS.has(pr.user.login)) { if (MAINTAINER_LOGINS.has(pr.user.login)) {
return true; return true;
@@ -603,7 +620,7 @@ async function checkPullRequest({
repo, repo,
pullNumber, pullNumber,
dryRun, dryRun,
skipCreatedBefore, minimumAutomaticPullNumber,
}) { }) {
const response = await github.rest.pulls.get({ const response = await github.rest.pulls.get({
owner, owner,
@@ -614,11 +631,11 @@ async function checkPullRequest({
const issueNumber = pr.number; const issueNumber = pr.number;
if ( if (
skipCreatedBefore != null && minimumAutomaticPullNumber != null &&
wasCreatedBefore(pr.created_at, skipCreatedBefore) pr.number < minimumAutomaticPullNumber
) { ) {
core.notice( core.notice(
`Skipping contribution policy for PR #${pr.number} because it was created before ${AUTOMATIC_PR_CREATED_AFTER_LABEL}.`, `Skipping contribution policy for PR #${pr.number} because automatic checks start at PR #${minimumAutomaticPullNumber}.`,
); );
return { return {
blocked: false, blocked: false,
@@ -626,7 +643,7 @@ async function checkPullRequest({
summary: summarizeResult({ summary: summarizeResult({
pr, pr,
skipped: true, skipped: true,
skipReason: `created before ${AUTOMATIC_PR_CREATED_AFTER_LABEL}`, skipReason: `before automatic rollout PR #${minimumAutomaticPullNumber}`,
}), }),
skipped: true, skipped: true,
}; };
@@ -756,8 +773,8 @@ async function run({ github, context, core }) {
context.eventName === "workflow_dispatch" && context.eventName === "workflow_dispatch" &&
dryRunInput !== false && dryRunInput !== false &&
dryRunInput !== "false"; dryRunInput !== "false";
const skipCreatedBefore = const minimumAutomaticPullNumber =
payloadPr == null ? null : AUTOMATIC_PR_CREATED_AFTER; payloadPr == null ? null : MIN_AUTOMATIC_PR_NUMBER;
let pullNumbers; let pullNumbers;
if (payloadPr != null) { if (payloadPr != null) {
@@ -795,7 +812,7 @@ async function run({ github, context, core }) {
repo, repo,
pullNumber: pr.number, pullNumber: pr.number,
dryRun, dryRun,
skipCreatedBefore, minimumAutomaticPullNumber,
}), }),
); );
} }
+1 -1
View File
@@ -30,4 +30,4 @@ jobs:
- name: Run JS Tests - name: Run JS Tests
run: vp test run: vp test
- name: Run Rust Tests - name: Run Rust Tests
run: cargo test --all run: cargo test --all --features yaak-app-client/wry
-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:*)'
+2 -1
View File
@@ -30,13 +30,14 @@ 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:
- name: Checkout policy script - name: Checkout policy script
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: ${{ github.event.pull_request.base.sha || github.ref }} ref: main
fetch-depth: 1 fetch-depth: 1
- name: Check contribution policy - name: Check contribution policy
+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:
+59 -10
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
@@ -14,35 +15,59 @@ jobs:
matrix: matrix:
include: include:
- platform: "macos-latest" # for Arm-based Macs (M1 and above). - platform: "macos-latest" # for Arm-based Macs (M1 and above).
args: "--target aarch64-apple-darwin" args: '--target aarch64-apple-darwin --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
yaak_arch: "arm64" yaak_arch: "arm64"
os: "macos" os: "macos"
runtime: "wry"
targets: "aarch64-apple-darwin" targets: "aarch64-apple-darwin"
- platform: "macos-latest" # for Intel-based Macs. - platform: "macos-latest" # for Intel-based Macs.
args: "--target x86_64-apple-darwin" args: '--target x86_64-apple-darwin --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
yaak_arch: "x64" yaak_arch: "x64"
os: "macos" os: "macos"
runtime: "wry"
targets: "x86_64-apple-darwin" targets: "x86_64-apple-darwin"
- platform: "ubuntu-22.04" - platform: "ubuntu-22.04"
args: "" args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
yaak_arch: "x64" yaak_arch: "x64"
os: "ubuntu" os: "ubuntu"
runtime: "wry"
targets: "" targets: ""
- platform: "ubuntu-22.04-arm" - platform: "ubuntu-22.04-arm"
args: "" args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
yaak_arch: "arm64" yaak_arch: "arm64"
os: "ubuntu" os: "ubuntu"
runtime: "wry"
targets: ""
- platform: "ubuntu-22.04"
args: >-
--bundles deb
--config ./tauri.release.conf.json
--config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false}}'
yaak_arch: "x64"
os: "ubuntu"
runtime: "cef"
targets: ""
- platform: "ubuntu-22.04-arm"
args: >-
--bundles deb
--config ./tauri.release.conf.json
--config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false}}'
yaak_arch: "arm64"
os: "ubuntu"
runtime: "cef"
targets: "" targets: ""
- platform: "windows-latest" - platform: "windows-latest"
args: "" args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
yaak_arch: "x64" yaak_arch: "x64"
os: "windows" os: "windows"
runtime: "wry"
targets: "" targets: ""
# Windows ARM64 # Windows ARM64
- platform: "windows-latest" - platform: "windows-latest"
args: "--target aarch64-pc-windows-msvc" args: '--target aarch64-pc-windows-msvc --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
yaak_arch: "arm64" yaak_arch: "arm64"
os: "windows" os: "windows"
runtime: "wry"
targets: "aarch64-pc-windows-msvc" targets: "aarch64-pc-windows-msvc"
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
timeout-minutes: 40 timeout-minutes: 40
@@ -66,11 +91,18 @@ jobs:
shared-key: ci shared-key: ci
cache-on-failure: true cache-on-failure: true
- name: Cache CEF (Linux only)
if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
uses: actions/cache@v4
with:
path: ~/.cache/tauri-cef
key: cef-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock') }}
- name: install dependencies (Linux only) - name: install dependencies (Linux only)
if: matrix.os == 'ubuntu' if: matrix.os == 'ubuntu'
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils
- name: Install Protoc for plugin-runtime - name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3 uses: arduino/setup-protoc@v3
@@ -98,7 +130,7 @@ jobs:
- name: Run JS Tests - name: Run JS Tests
run: vp test run: vp test
- name: Run Rust Tests - name: Run Rust Tests
run: cargo test --all --exclude yaak-cli run: cargo test --all --exclude yaak-cli --features yaak-app-client/wry
- name: Set version - name: Set version
run: npm run replace-version run: npm run replace-version
@@ -150,13 +182,30 @@ jobs:
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }} AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }} AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
with: with:
tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js"
tagName: "v__VERSION__" tagName: "v__VERSION__"
releaseName: "Release __VERSION__" releaseName: "Release __VERSION__"
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)" releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
releaseDraft: true releaseDraft: true
prerelease: true prerelease: true
projectPath: ./crates-tauri/yaak-app-client projectPath: ./crates-tauri/yaak-app-client
args: "${{ matrix.args }} --config ./tauri.release.conf.json" 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)
@@ -173,7 +222,7 @@ jobs:
run: | run: |
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
Push-Location crates-tauri/yaak-app-client Push-Location crates-tauri/yaak-app-client
npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}' npx tauri bundle ${{ matrix.args }} --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
Pop-Location Pop-Location
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1 $setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
$setupSig = "$($setup.FullName).sig" $setupSig = "$($setup.FullName).sig"
+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
+979 -115
View File
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -31,6 +31,7 @@ members = [
"crates-tauri/yaak-fonts", "crates-tauri/yaak-fonts",
"crates-tauri/yaak-license", "crates-tauri/yaak-license",
"crates-tauri/yaak-mac-window", "crates-tauri/yaak-mac-window",
"crates-tauri/yaak-system-appearance",
"crates-tauri/yaak-tauri-utils", "crates-tauri/yaak-tauri-utils",
"crates-tauri/yaak-window", "crates-tauri/yaak-window",
] ]
@@ -47,9 +48,13 @@ schemars = { version = "0.8.22", features = ["chrono"] }
serde = "1.0.228" serde = "1.0.228"
serde_json = "1.0.145" serde_json = "1.0.145"
sha2 = "0.10.9" sha2 = "0.10.9"
tauri = "2.11.1" tauri = { version = "2.11.1", default-features = false, features = [
"common-controls-v6",
"compression",
"dynamic-acl",
] }
tauri-plugin = "2.6.1" tauri-plugin = "2.6.1"
tauri-plugin-dialog = "2.7.1" tauri-plugin-dialog = { version = "2.7.1", default-features = false }
tauri-plugin-shell = "2.3.5" tauri-plugin-shell = "2.3.5"
thiserror = "2.0.17" thiserror = "2.0.17"
tokio = "1.48.0" tokio = "1.48.0"
@@ -84,8 +89,13 @@ yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
yaak-fonts = { path = "crates-tauri/yaak-fonts" } yaak-fonts = { path = "crates-tauri/yaak-fonts" }
yaak-license = { path = "crates-tauri/yaak-license" } yaak-license = { path = "crates-tauri/yaak-license" }
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" } yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" }
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" } yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
yaak-window = { path = "crates-tauri/yaak-window" } yaak-window = { path = "crates-tauri/yaak-window" }
[profile.release] [profile.release]
strip = false strip = false
[patch.crates-io]
tauri = { git = "https://github.com/tauri-apps/tauri", rev = "d9bc695c18d9a25baec21d8a5f36d72e3a14ee53" }
tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "d9bc695c18d9a25baec21d8a5f36d72e3a14ee53" }
@@ -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,11 +112,22 @@ 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"
color="info" color="info"
className="p-4 max-w-xl mx-auto" className="w-full p-4 max-w-xl mr-auto"
> >
<p> <p>
Workspace specific settings have moved to{" "} Workspace specific settings have moved to{" "}
@@ -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);
+51 -11
View File
@@ -1,32 +1,49 @@
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { type as osType } from "@tauri-apps/plugin-os";
import { setWindowTheme } from "@yaakapp-internal/mac-window"; import { setWindowTheme } from "@yaakapp-internal/mac-window";
import type { ModelPayload } from "@yaakapp-internal/models"; import type { ModelPayload } from "@yaakapp-internal/models";
import type { Appearance } from "@yaakapp-internal/theme"; import type { Appearance } from "@yaakapp-internal/theme";
import { import {
applyThemeToDocument, applyThemeToDocument,
getCSSAppearance, getCSSAppearance,
subscribeToPreferredAppearance, subscribeToPreferredAppearanceChange,
subscribeToSystemAppearanceChange,
} from "@yaakapp-internal/theme"; } from "@yaakapp-internal/theme";
import { getSettings } from "./lib/settings"; import { getSettings } from "./lib/settings";
import { getResolvedTheme } from "./lib/themes"; import { getResolvedTheme } from "./lib/themes";
// NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want // NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want
// a good appearance guess so we're not waiting too long // a good appearance guess so we're not waiting too long
let preferredAppearance: Appearance = getCSSAppearance(); let preferredAppearance: Appearance = getInitialAppearance();
subscribeToPreferredAppearance(async (a) => { let linuxSystemAppearanceAvailable =
osType() === "linux" && window.__YAAK_INITIAL_APPEARANCE_SOURCE__ === "linux-system";
let configureThemeGeneration = 0;
let windowShown = false;
configureThemeAndShow().catch((err) => console.log("Failed to configure theme", err));
subscribeToPreferredAppearanceChange(async (a) => {
if (linuxSystemAppearanceAvailable) return;
preferredAppearance = a; preferredAppearance = a;
await configureTheme(); await configureThemeAndShow();
}); });
configureTheme().then( subscribeToSystemAppearanceChange(async (a) => {
async () => { linuxSystemAppearanceAvailable = true;
preferredAppearance = a;
await configureThemeAndShow();
});
async function configureThemeAndShow() {
const applied = await configureTheme();
if (applied && !windowShown) {
windowShown = true;
// To prevent theme flashing, the backend hides new windows by default, so we // To prevent theme flashing, the backend hides new windows by default, so we
// need to show it here, after configuring the theme for the first time. // need to show it here, after configuring the theme for the first time.
await getCurrentWebviewWindow().show(); await getCurrentWebviewWindow().show();
}, }
(err) => console.log("Failed to configure theme", err), }
);
// Listen for settings changes, the re-compute theme // Listen for settings changes, the re-compute theme
listen<ModelPayload>("model_write", async (event) => { listen<ModelPayload>("model_write", async (event) => {
@@ -34,10 +51,11 @@ listen<ModelPayload>("model_write", async (event) => {
const model = event.payload.model.model; const model = event.payload.model.model;
if (model !== "settings" && model !== "plugin") return; if (model !== "settings" && model !== "plugin") return;
await configureTheme(); await configureThemeAndShow();
}).catch(console.error); }).catch(console.error);
async function configureTheme() { async function configureTheme(): Promise<boolean> {
const generation = ++configureThemeGeneration;
const settings = await getSettings(); const settings = await getSettings();
const theme = await getResolvedTheme( const theme = await getResolvedTheme(
preferredAppearance, preferredAppearance,
@@ -45,8 +63,30 @@ async function configureTheme() {
settings.themeLight, settings.themeLight,
settings.themeDark, settings.themeDark,
); );
if (generation !== configureThemeGeneration) {
return false;
}
applyThemeToDocument(theme.active); applyThemeToDocument(theme.active);
if (theme.active.base.surface != null) { if (theme.active.base.surface != null) {
setWindowTheme(theme.active.base.surface); setWindowTheme(theme.active.base.surface);
} }
return true;
}
function getInitialAppearance(): Appearance {
const initialAppearance = window.__YAAK_INITIAL_APPEARANCE__;
if (initialAppearance === "dark" || initialAppearance === "light") {
return initialAppearance;
}
return getCSSAppearance();
}
declare global {
interface Window {
__YAAK_INITIAL_APPEARANCE__?: Appearance;
__YAAK_INITIAL_APPEARANCE_SOURCE__?: "settings" | "linux-system";
}
} }
+1 -3
View File
@@ -855,8 +855,6 @@ exports.plugin = {
assert_eq!(metadata["apis"]["workspaceActions"]["items"][0]["label"], "Sync workspace"); assert_eq!(metadata["apis"]["workspaceActions"]["items"][0]["label"], "Sync workspace");
assert_eq!(metadata["apis"]["lifecycle"]["items"][0]["name"], "init"); assert_eq!(metadata["apis"]["lifecycle"]["items"][0]["name"], "init");
assert!(metadata["apis"]["templateFunctions"]["items"][0]["onRender"].is_null()); assert!(metadata["apis"]["templateFunctions"]["items"][0]["onRender"].is_null());
assert!( assert!(metadata["apis"]["templateFunctions"]["items"][0]["args"][0]["dynamic"].is_null());
metadata["apis"]["templateFunctions"]["items"][0]["args"][0]["dynamic"].is_null()
);
} }
} }
+1 -5
View File
@@ -470,11 +470,7 @@ async fn build_plugin_reply(
} }
}; };
let names = cookie_jar let names = cookie_jar.cookies.into_iter().map(|c| c.name).collect();
.cookies
.into_iter()
.map(|c| c.name)
.collect();
Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse { Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse {
names, names,
+6 -1
View File
@@ -13,6 +13,8 @@ crate-type = ["staticlib", "cdylib", "lib"]
[features] [features]
cargo-clippy = [] cargo-clippy = []
default = [] default = []
cef = ["tauri/cef", "tauri-plugin-dialog/xdg-portal"]
wry = ["tauri/wry", "tauri/x11", "tauri/dbus", "tauri-plugin-dialog/gtk3"]
updater = [] updater = []
license = ["yaak-license"] license = ["yaak-license"]
@@ -22,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"] }
@@ -59,7 +64,6 @@ tauri-plugin-os = "2.3.2"
tauri-plugin-shell = { workspace = true } tauri-plugin-shell = { workspace = true }
tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] } tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
tauri-plugin-updater = "2.10.1" tauri-plugin-updater = "2.10.1"
tauri-plugin-window-state = "2.4.1"
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync"] } tokio = { workspace = true, features = ["sync"] }
tokio-stream = "0.1.17" tokio-stream = "0.1.17"
@@ -83,6 +87,7 @@ yaak-mac-window = { workspace = true }
yaak-models = { workspace = true } yaak-models = { workspace = true }
yaak-plugins = { workspace = true } yaak-plugins = { workspace = true }
yaak-sse = { workspace = true } yaak-sse = { workspace = true }
yaak-system-appearance = { workspace = true }
yaak-sync = { workspace = true } yaak-sync = { workspace = true }
yaak-templates = { workspace = true } yaak-templates = { workspace = true }
yaak-tls = { workspace = true } yaak-tls = { workspace = true }
@@ -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}")
}
}
+73 -22
View File
@@ -26,7 +26,6 @@ use tauri::{Manager, WindowEvent};
use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_log::fern::colors::ColoredLevelConfig; use tauri_plugin_log::fern::colors::ColoredLevelConfig;
use tauri_plugin_log::{Builder, Target, TargetKind, log}; use tauri_plugin_log::{Builder, Target, TargetKind, log};
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::task::block_in_place; use tokio::task::block_in_place;
use tokio::time; use tokio::time;
@@ -66,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;
@@ -83,6 +83,14 @@ mod uri_scheme;
mod window_menu; mod window_menu;
mod ws_ext; mod ws_ext;
#[cfg(not(any(feature = "cef", feature = "wry")))]
compile_error!("Enable one Tauri runtime feature: `cef` or `wry`.");
#[cfg(feature = "cef")]
type TauriRuntime = tauri::Cef;
#[cfg(all(not(feature = "cef"), feature = "wry"))]
type TauriRuntime = tauri::Wry;
fn setup_window_menu<R: Runtime>(win: &WebviewWindow<R>) -> Result<()> { fn setup_window_menu<R: Runtime>(win: &WebviewWindow<R>) -> Result<()> {
#[allow(unused_variables)] #[allow(unused_variables)]
let menu = window_menu::app_menu(win.app_handle())?; let menu = window_menu::app_menu(win.app_handle())?;
@@ -151,6 +159,22 @@ fn setup_window_menu<R: Runtime>(win: &WebviewWindow<R>) -> Result<()> {
Ok(()) Ok(())
} }
fn initial_appearance_script<R: Runtime>(app_handle: &AppHandle<R>) -> Option<String> {
use yaak_system_appearance::{Appearance, InitialAppearanceSource};
let settings = app_handle.db().get_settings();
let (appearance, source) = match settings.appearance.as_str() {
"dark" => (Appearance::Dark, InitialAppearanceSource::Settings),
"light" => (Appearance::Light, InitialAppearanceSource::Settings),
_ => (
yaak_system_appearance::system_appearance()?,
InitialAppearanceSource::LinuxSystem,
),
};
Some(yaak_system_appearance::initialization_script(appearance, source))
}
/// Extension trait for easily creating a PluginContext from a WebviewWindow /// Extension trait for easily creating a PluginContext from a WebviewWindow
pub trait PluginContextExt<R: Runtime> { pub trait PluginContextExt<R: Runtime> {
fn plugin_context(&self) -> PluginContext; fn plugin_context(&self) -> PluginContext;
@@ -178,7 +202,7 @@ struct AppMetaData {
} }
#[tauri::command] #[tauri::command]
async fn cmd_metadata(app_handle: AppHandle) -> YaakResult<AppMetaData> { async fn cmd_metadata<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<AppMetaData> {
let app_data_dir = app_handle.path().app_data_dir()?; let app_data_dir = app_handle.path().app_data_dir()?;
let app_log_dir = app_handle.path().app_log_dir()?; let app_log_dir = app_handle.path().app_log_dir()?;
let vendored_plugin_dir = let vendored_plugin_dir =
@@ -269,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>,
@@ -963,7 +997,7 @@ async fn cmd_send_ephemeral_request<R: Runtime>(
mut request: HttpRequest, mut request: HttpRequest,
environment_id: Option<&str>, environment_id: Option<&str>,
cookie_jar_id: Option<&str>, cookie_jar_id: Option<&str>,
window: WebviewWindow, window: WebviewWindow<R>,
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
) -> YaakResult<HttpResponse> { ) -> YaakResult<HttpResponse> {
let response = HttpResponse::default(); let response = HttpResponse::default();
@@ -1589,20 +1623,22 @@ async fn cmd_get_workspace_meta<R: Runtime>(
} }
#[tauri::command] #[tauri::command]
async fn cmd_new_child_window( async fn cmd_new_child_window<R: Runtime>(
parent_window: WebviewWindow, parent_window: WebviewWindow<R>,
url: &str, url: &str,
label: &str, label: &str,
title: &str, title: &str,
inner_size: (f64, f64), inner_size: (f64, f64),
) -> YaakResult<()> { ) -> YaakResult<()> {
let use_native_titlebar = parent_window.app_handle().db().get_settings().use_native_titlebar; let use_native_titlebar = parent_window.app_handle().db().get_settings().use_native_titlebar;
let initialization_script = initial_appearance_script(&parent_window.app_handle());
let win = yaak_window::window::create_child_window( let win = yaak_window::window::create_child_window(
&parent_window, &parent_window,
url, url,
label, label,
title, title,
inner_size, inner_size,
initialization_script,
use_native_titlebar, use_native_titlebar,
)?; )?;
setup_window_menu(&win)?; setup_window_menu(&win)?;
@@ -1610,9 +1646,15 @@ async fn cmd_new_child_window(
} }
#[tauri::command] #[tauri::command]
async fn cmd_new_main_window(app_handle: AppHandle, url: &str) -> YaakResult<()> { async fn cmd_new_main_window<R: Runtime>(app_handle: AppHandle<R>, url: &str) -> YaakResult<()> {
let use_native_titlebar = app_handle.db().get_settings().use_native_titlebar; let use_native_titlebar = app_handle.db().get_settings().use_native_titlebar;
let win = yaak_window::window::create_main_window(&app_handle, url, use_native_titlebar)?; let initialization_script = initial_appearance_script(&app_handle);
let win = yaak_window::window::create_main_window(
&app_handle,
url,
initialization_script,
use_native_titlebar,
)?;
setup_window_menu(&win)?; setup_window_menu(&win)?;
Ok(()) Ok(())
} }
@@ -1632,8 +1674,17 @@ 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)]
pub fn run() { pub fn run() {
let mut builder = tauri::Builder::default().plugin( // 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(
Builder::default() Builder::default()
.targets([ .targets([
Target::new(TargetKind::Stdout), Target::new(TargetKind::Stdout),
@@ -1677,13 +1728,6 @@ pub fn run() {
builder = builder builder = builder
.plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
// Don't restore StateFlags::DECORATIONS because we want to be able to toggle them on/off on a restart
// We could* make this work if we toggled them in the frontend before the window closes, but, this is nicer.
.plugin(
tauri_plugin_window_state::Builder::new()
.with_state_flags(StateFlags::all() - StateFlags::DECORATIONS)
.build(),
)
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
@@ -1714,6 +1758,10 @@ pub fn run() {
app.state::<yaak_models::query_manager::QueryManager>().inner().clone(); app.state::<yaak_models::query_manager::QueryManager>().inner().clone();
let app_id = app.config().identifier.to_string(); let app_id = app.config().identifier.to_string();
app.manage(yaak_crypto::manager::EncryptionManager::new(query_manager, app_id)); app.manage(yaak_crypto::manager::EncryptionManager::new(query_manager, app_id));
#[cfg(target_os = "linux")]
if let Some(state) = yaak_system_appearance::watch(app.app_handle().clone()) {
app.manage(state);
}
{ {
let app_handle = app.app_handle().clone(); let app_handle = app.app_handle().clone();
@@ -1790,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,
@@ -1902,9 +1951,11 @@ pub fn run() {
match event { match event {
RunEvent::Ready => { RunEvent::Ready => {
let use_native_titlebar = app_handle.db().get_settings().use_native_titlebar; let use_native_titlebar = app_handle.db().get_settings().use_native_titlebar;
let initialization_script = initial_appearance_script(app_handle);
if let Ok(win) = yaak_window::window::create_main_window( if let Ok(win) = yaak_window::window::create_main_window(
app_handle, app_handle,
"/", "/",
initialization_script,
use_native_titlebar, use_native_titlebar,
) { ) {
let _ = setup_window_menu(&win); let _ = setup_window_menu(&win);
@@ -1925,6 +1976,13 @@ pub fn run() {
}); });
} }
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => { RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
#[cfg(target_os = "linux")]
if let Some(state) =
app_handle.try_state::<yaak_system_appearance::SystemAppearanceState>()
{
yaak_system_appearance::emit_change(app_handle, &state);
}
if cfg!(feature = "updater") { if cfg!(feature = "updater") {
// Run update check whenever the window is focused // Run update check whenever the window is focused
let w = app_handle.get_webview_window(&label).unwrap(); let w = app_handle.get_webview_window(&label).unwrap();
@@ -1959,13 +2017,6 @@ pub fn run() {
} }
}); });
} }
RunEvent::WindowEvent { event: WindowEvent::CloseRequested { .. }, .. } => {
if let Err(e) = app_handle.save_window_state(StateFlags::all()) {
warn!("Failed to save window state {e:?}");
} else {
info!("Saved window state");
};
}
_ => {} _ => {}
}; };
}); });
+2 -1
View File
@@ -6,7 +6,8 @@
"beforeBuildCommand": "npm --prefix ../.. run client:tauri-before-build", "beforeBuildCommand": "npm --prefix ../.. run client:tauri-before-build",
"beforeDevCommand": "npm --prefix ../.. run client:tauri-before-dev", "beforeDevCommand": "npm --prefix ../.. run client:tauri-before-dev",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
"frontendDist": "../../dist/apps/yaak-client" "frontendDist": "../../dist/apps/yaak-client",
"features": ["wry"]
}, },
"app": { "app": {
"withGlobalTauri": false, "withGlobalTauri": false,
@@ -1,6 +1,6 @@
{ {
"build": { "build": {
"features": ["updater", "license"] "features": ["updater", "license", "wry"]
}, },
"app": { "app": {
"security": { "security": {
+4
View File
@@ -12,6 +12,10 @@ crate-type = ["staticlib", "cdylib", "lib"]
[build-dependencies] [build-dependencies]
tauri-build = { version = "2.6.1", features = [] } tauri-build = { version = "2.6.1", features = [] }
[features]
default = ["wry"]
wry = ["tauri/wry", "tauri/x11", "tauri/dbus"]
[dependencies] [dependencies]
log = { workspace = true } log = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
+1 -1
View File
@@ -92,7 +92,7 @@ pub fn run() {
label: "main_0", label: "main_0",
title: "Yaak Proxy", title: "Yaak Proxy",
inner_size: Some((1000.0, 700.0)), inner_size: Some((1000.0, 700.0)),
visible: false, hidden: true,
hide_titlebar: true, hide_titlebar: true,
..Default::default() ..Default::default()
}; };
@@ -0,0 +1,12 @@
[package]
name = "yaak-system-appearance"
version = "0.1.0"
edition = "2024"
publish = false
[target.'cfg(target_os = "linux")'.dependencies]
dark-light = "2.0.0"
[dependencies]
log = { workspace = true }
tauri = { workspace = true }
@@ -0,0 +1,158 @@
use std::sync::{Arc, Mutex};
#[cfg(target_os = "linux")]
use std::time::Duration;
#[cfg(target_os = "linux")]
use log::{debug, warn};
#[cfg(target_os = "linux")]
use tauri::Emitter;
use tauri::{AppHandle, Runtime};
pub const INITIAL_APPEARANCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__";
pub const INITIAL_APPEARANCE_SOURCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE_SOURCE__";
pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change";
#[cfg(target_os = "linux")]
const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Appearance {
Dark,
Light,
}
impl Appearance {
pub fn as_str(self) -> &'static str {
match self {
Self::Dark => "dark",
Self::Light => "light",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InitialAppearanceSource {
Settings,
LinuxSystem,
}
impl InitialAppearanceSource {
fn as_str(self) -> &'static str {
match self {
Self::Settings => "settings",
Self::LinuxSystem => "linux-system",
}
}
}
#[derive(Clone)]
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>>>,
}
pub fn initialization_script(appearance: Appearance, source: InitialAppearanceSource) -> String {
let appearance = appearance.as_str();
let source = source.as_str();
format!(
"window.{INITIAL_APPEARANCE_GLOBAL} = {appearance:?};\
window.{INITIAL_APPEARANCE_SOURCE_GLOBAL} = {source:?};"
)
}
#[cfg(target_os = "linux")]
pub fn system_appearance() -> Option<Appearance> {
if let Some(appearance) = gsettings_system_appearance() {
return Some(appearance);
}
match dark_light::detect() {
Ok(dark_light::Mode::Dark) => Some(Appearance::Dark),
Ok(dark_light::Mode::Light) => Some(Appearance::Light),
Ok(dark_light::Mode::Unspecified) => None,
Err(err) => {
debug!("Failed to detect Linux system appearance: {err:?}");
None
}
}
}
#[cfg(not(target_os = "linux"))]
pub fn system_appearance() -> Option<Appearance> {
None
}
#[cfg(target_os = "linux")]
pub fn watch<R: Runtime>(app_handle: AppHandle<R>) -> Option<SystemAppearanceState> {
let last_appearance = system_appearance();
if last_appearance.is_none() {
debug!("Linux system appearance detection unavailable");
return None;
}
let state = SystemAppearanceState { last_appearance: Arc::new(Mutex::new(last_appearance)) };
let thread_state = state.clone();
let _ = std::thread::spawn(move || {
loop {
std::thread::sleep(SYSTEM_APPEARANCE_POLL_INTERVAL);
emit_change(&app_handle, &thread_state);
}
});
Some(state)
}
#[cfg(not(target_os = "linux"))]
pub fn watch<R: Runtime>(_app_handle: AppHandle<R>) -> Option<SystemAppearanceState> {
None
}
#[cfg(target_os = "linux")]
pub fn emit_change<R: Runtime>(app_handle: &AppHandle<R>, state: &SystemAppearanceState) {
let appearance = system_appearance();
let mut last_appearance =
state.last_appearance.lock().expect("system appearance lock poisoned");
if appearance == *last_appearance {
return;
}
*last_appearance = appearance;
if let Some(appearance) = appearance {
let appearance = appearance.as_str();
debug!("System appearance changed to {appearance}");
if let Err(err) = app_handle.emit(SYSTEM_APPEARANCE_CHANGE_EVENT, appearance) {
warn!("Failed to emit system appearance change: {err:?}");
}
}
}
#[cfg(target_os = "linux")]
fn gsettings_system_appearance() -> Option<Appearance> {
let color_scheme = std::process::Command::new("gsettings")
.args(["get", "org.gnome.desktop.interface", "color-scheme"])
.output()
.ok()
.and_then(|output| String::from_utf8(output.stdout).ok())
.unwrap_or_default();
if color_scheme.contains("prefer-dark") {
return Some(Appearance::Dark);
}
if color_scheme.contains("prefer-light") {
return Some(Appearance::Light);
}
let gtk_theme = std::process::Command::new("gsettings")
.args(["get", "org.gnome.desktop.interface", "gtk-theme"])
.output()
.ok()
.and_then(|output| String::from_utf8(output.stdout).ok())
.unwrap_or_default();
if gtk_theme.to_lowercase().contains("dark") {
return Some(Appearance::Dark);
}
(!gtk_theme.trim().is_empty()).then_some(Appearance::Light)
}
+2
View File
@@ -8,5 +8,7 @@ publish = false
log = { workspace = true } log = { workspace = true }
md5 = "0.8.0" md5 = "0.8.0"
rand = "0.9.0" rand = "0.9.0"
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tauri = { workspace = true } tauri = { workspace = true }
tokio = { workspace = true, features = ["sync"] } tokio = { workspace = true, features = ["sync"] }
+2
View File
@@ -1 +1,3 @@
mod window_state;
pub mod window; pub mod window;
+44 -10
View File
@@ -1,3 +1,4 @@
use crate::window_state;
use log::info; use log::info;
use rand::random; use rand::random;
use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindow, WindowEvent}; use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindow, WindowEvent};
@@ -11,18 +12,22 @@ const MIN_WINDOW_HEIGHT: f64 = 300.0;
pub const MAIN_WINDOW_PREFIX: &str = "main_"; pub const MAIN_WINDOW_PREFIX: &str = "main_";
const OTHER_WINDOW_PREFIX: &str = "other_"; const OTHER_WINDOW_PREFIX: &str = "other_";
const MAIN_WINDOW_STATE_KEY: &str = "main";
#[derive(Default, Debug)] #[derive(Default, Debug)]
pub struct CreateWindowConfig<'s> { pub struct CreateWindowConfig<'s> {
pub url: &'s str, pub url: &'s str,
pub label: &'s str, pub label: &'s str,
pub title: &'s str, pub title: &'s str,
pub state_key: Option<String>,
pub inner_size: Option<(f64, f64)>, pub inner_size: Option<(f64, f64)>,
pub position: Option<(f64, f64)>, pub position: Option<(f64, f64)>,
pub restore_position: Option<bool>,
pub navigation_tx: Option<mpsc::Sender<String>>, pub navigation_tx: Option<mpsc::Sender<String>>,
pub close_tx: Option<mpsc::Sender<()>>, pub close_tx: Option<mpsc::Sender<()>>,
pub data_dir_key: Option<String>, pub data_dir_key: Option<String>,
pub visible: bool, pub initialization_script: Option<String>,
pub hidden: bool,
pub hide_titlebar: bool, pub hide_titlebar: bool,
pub use_native_titlebar: bool, pub use_native_titlebar: bool,
} }
@@ -32,15 +37,33 @@ pub fn create_window<R: Runtime>(
config: CreateWindowConfig, config: CreateWindowConfig,
) -> tauri::Result<WebviewWindow<R>> { ) -> tauri::Result<WebviewWindow<R>> {
info!("Create new window label={}", config.label); info!("Create new window label={}", config.label);
let state_key = config.state_key.clone().unwrap_or_else(|| config.label.to_string());
let restore_position = config.restore_position.unwrap_or(true);
let mut inner_size = config.inner_size;
let mut position = config.position;
let mut maximized = false;
window_state::apply_saved_state(
handle,
&state_key,
&mut inner_size,
&mut position,
&mut maximized,
restore_position,
);
let mut win_builder = let mut win_builder =
tauri::WebviewWindowBuilder::new(handle, config.label, WebviewUrl::App(config.url.into())) tauri::WebviewWindowBuilder::new(handle, config.label, WebviewUrl::App(config.url.into()))
.title(config.title) .title(config.title)
.resizable(true) .resizable(true)
.visible(config.visible) .visible(!config.hidden)
.fullscreen(false) .fullscreen(false)
.maximized(maximized)
.min_inner_size(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT); .min_inner_size(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT);
if let Some(script) = config.initialization_script {
win_builder = win_builder.initialization_script(script);
}
if let Some(key) = config.data_dir_key { if let Some(key) = config.data_dir_key {
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
{ {
@@ -61,13 +84,13 @@ pub fn create_window<R: Runtime>(
} }
} }
if let Some((w, h)) = config.inner_size { if let Some((w, h)) = inner_size {
win_builder = win_builder.inner_size(w, h); win_builder = win_builder.inner_size(w, h);
} else { } else {
win_builder = win_builder.inner_size(600.0, 600.0); win_builder = win_builder.inner_size(600.0, 600.0);
} }
if let Some((x, y)) = config.position { if let Some((x, y)) = position {
win_builder = win_builder.position(x, y); win_builder = win_builder.position(x, y);
} else { } else {
win_builder = win_builder.center(); win_builder = win_builder.center();
@@ -103,6 +126,7 @@ pub fn create_window<R: Runtime>(
} }
let win = win_builder.build()?; let win = win_builder.build()?;
window_state::track_window(&win, &state_key);
if let Some(tx) = config.close_tx { if let Some(tx) = config.close_tx {
win.on_window_event(move |event| match event { win.on_window_event(move |event| match event {
@@ -119,11 +143,12 @@ pub fn create_window<R: Runtime>(
Ok(win) Ok(win)
} }
pub fn create_main_window( pub fn create_main_window<R: Runtime>(
handle: &AppHandle, handle: &AppHandle<R>,
url: &str, url: &str,
initialization_script: Option<String>,
use_native_titlebar: bool, use_native_titlebar: bool,
) -> tauri::Result<WebviewWindow> { ) -> tauri::Result<WebviewWindow<R>> {
let mut counter = 0; let mut counter = 0;
let label = loop { let label = loop {
let label = format!("{MAIN_WINDOW_PREFIX}{counter}"); let label = format!("{MAIN_WINDOW_PREFIX}{counter}");
@@ -138,12 +163,16 @@ pub fn create_main_window(
url, url,
label: label.as_str(), label: label.as_str(),
title: "Yaak", title: "Yaak",
state_key: Some(MAIN_WINDOW_STATE_KEY.to_string()),
inner_size: Some((DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT)), inner_size: Some((DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT)),
position: Some(( position: Some((
// Offset by random amount so it's easier to differentiate // Offset by random amount so it's easier to differentiate
100.0 + random::<f64>() * 20.0, 100.0 + random::<f64>() * 20.0,
100.0 + random::<f64>() * 20.0, 100.0 + random::<f64>() * 20.0,
)), )),
restore_position: Some(counter == 0),
initialization_script,
hidden: true,
hide_titlebar: true, hide_titlebar: true,
use_native_titlebar, use_native_titlebar,
..Default::default() ..Default::default()
@@ -152,15 +181,17 @@ pub fn create_main_window(
create_window(handle, config) create_window(handle, config)
} }
pub fn create_child_window( pub fn create_child_window<R: Runtime>(
parent_window: &WebviewWindow, parent_window: &WebviewWindow<R>,
url: &str, url: &str,
label: &str, label: &str,
title: &str, title: &str,
inner_size: (f64, f64), inner_size: (f64, f64),
initialization_script: Option<String>,
use_native_titlebar: bool, use_native_titlebar: bool,
) -> tauri::Result<WebviewWindow> { ) -> tauri::Result<WebviewWindow<R>> {
let app_handle = parent_window.app_handle(); let app_handle = parent_window.app_handle();
let state_key = label.to_string();
let label = format!("{OTHER_WINDOW_PREFIX}_{label}"); let label = format!("{OTHER_WINDOW_PREFIX}_{label}");
let scale_factor = parent_window.scale_factor()?; let scale_factor = parent_window.scale_factor()?;
@@ -176,9 +207,12 @@ pub fn create_child_window(
let config = CreateWindowConfig { let config = CreateWindowConfig {
label: label.as_str(), label: label.as_str(),
title, title,
state_key: Some(state_key),
url, url,
inner_size: Some(inner_size), inner_size: Some(inner_size),
position: Some(position), position: Some(position),
initialization_script,
hidden: true,
hide_titlebar: true, hide_titlebar: true,
use_native_titlebar, use_native_titlebar,
..Default::default() ..Default::default()
@@ -0,0 +1,218 @@
use log::{debug, warn};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tauri::{AppHandle, Manager, Monitor, Runtime, WebviewWindow, WindowEvent};
const WINDOW_STATE_FILE: &str = "window-state.json";
const SAVE_DEBOUNCE: Duration = Duration::from_millis(1000);
static WINDOW_STATE_FILE_LOCK: Mutex<()> = Mutex::new(());
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
struct WindowState {
width: f64,
height: f64,
x: f64,
y: f64,
maximized: bool,
}
impl WindowState {
fn has_size(self) -> bool {
self.width > 0.0 && self.height > 0.0
}
fn has_position(self) -> bool {
self.x.is_finite() && self.y.is_finite()
}
}
pub fn apply_saved_state<R: Runtime>(
app_handle: &AppHandle<R>,
state_key: &str,
inner_size: &mut Option<(f64, f64)>,
position: &mut Option<(f64, f64)>,
maximized: &mut bool,
restore_position: bool,
) {
let Some(state) = read_window_state(app_handle, state_key) else {
debug!("No saved window state for {state_key}");
return;
};
debug!(
"Applying saved window state for {state_key}: width={} height={} x={} y={} maximized={} restore_position={restore_position}",
state.width, state.height, state.x, state.y, state.maximized
);
if state.has_size() {
*inner_size = Some((state.width, state.height));
}
if restore_position && state.has_position() {
if is_position_visible(app_handle, state) {
*position = Some((state.x, state.y));
} else {
debug!("Ignoring saved window position for {state_key} because it is off-screen");
}
}
*maximized = state.maximized;
}
pub fn track_window<R: Runtime>(window: &WebviewWindow<R>, state_key: &str) {
let state_key = state_key.to_string();
let save_generation = Arc::new(AtomicU64::new(0));
let tracked_window = window.clone();
window.clone().on_window_event(move |event| match event {
WindowEvent::Moved(_) | WindowEvent::Resized(_) => {
schedule_save(tracked_window.clone(), state_key.clone(), save_generation.clone());
}
WindowEvent::CloseRequested { .. } => {
save_generation.fetch_add(1, Ordering::Relaxed);
if let Err(e) = save_window_state(&tracked_window, &state_key) {
warn!("Failed to save window state for {state_key}: {e}");
}
}
_ => {}
});
}
fn schedule_save<R: Runtime>(
window: WebviewWindow<R>,
state_key: String,
save_generation: Arc<AtomicU64>,
) {
let generation = save_generation.fetch_add(1, Ordering::Relaxed) + 1;
let window_for_dispatch = window.clone();
std::thread::spawn(move || {
std::thread::sleep(SAVE_DEBOUNCE);
if save_generation.load(Ordering::Relaxed) != generation {
return;
}
let state_key_for_save = state_key.clone();
let window_for_save = window.clone();
if let Err(e) = window_for_dispatch.run_on_main_thread(move || {
if let Err(e) = save_window_state(&window_for_save, &state_key_for_save) {
warn!("Failed to save window state for {state_key_for_save}: {e}");
}
}) {
debug!("Failed to dispatch debounced window state save for {state_key}: {e}");
}
});
}
fn save_window_state<R: Runtime>(window: &WebviewWindow<R>, state_key: &str) -> tauri::Result<()> {
let app_handle = window.app_handle();
let state_path = window_state_path(&app_handle)?;
let _lock = WINDOW_STATE_FILE_LOCK.lock().unwrap();
let mut states = read_window_states(&state_path);
let mut state = states.get(state_key).copied().unwrap_or_default();
let maximized = window.is_maximized().unwrap_or(false);
let minimized = window.is_minimized().unwrap_or(false);
let scale_factor = window.scale_factor().unwrap_or(1.0);
if !minimized && (!maximized || !state.has_size()) {
let size = window.inner_size()?.to_logical::<f64>(scale_factor);
if size.width > 0.0 && size.height > 0.0 {
state.width = size.width;
state.height = size.height;
}
}
if !minimized && (!maximized || !state.has_position()) {
let position = window.outer_position()?.to_logical::<f64>(scale_factor);
state.x = position.x;
state.y = position.y;
}
state.maximized = maximized;
states.insert(state_key.to_string(), state);
write_window_states(&state_path, &states)?;
debug!(
"Saved window state for {state_key} to {}: width={} height={} x={} y={} maximized={} minimized={minimized}",
state_path.display(),
state.width,
state.height,
state.x,
state.y,
state.maximized
);
Ok(())
}
fn read_window_state<R: Runtime>(
app_handle: &AppHandle<R>,
state_key: &str,
) -> Option<WindowState> {
let state_path = window_state_path(app_handle).ok()?;
debug!("Reading window state for {state_key} from {}", state_path.display());
read_window_states(&state_path).get(state_key).copied()
}
fn window_state_path<R: Runtime>(app_handle: &AppHandle<R>) -> tauri::Result<PathBuf> {
Ok(app_handle.path().app_config_dir()?.join(WINDOW_STATE_FILE))
}
fn read_window_states(state_path: &PathBuf) -> HashMap<String, WindowState> {
let Ok(bytes) = fs::read(state_path) else {
return HashMap::new();
};
match serde_json::from_slice(&bytes) {
Ok(states) => states,
Err(e) => {
warn!("Failed to read window state {}: {e}", state_path.display());
HashMap::new()
}
}
}
fn write_window_states(
state_path: &PathBuf,
states: &HashMap<String, WindowState>,
) -> tauri::Result<()> {
if let Some(parent) = state_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(state_path, serde_json::to_vec_pretty(states)?)?;
Ok(())
}
fn is_position_visible<R: Runtime>(app_handle: &AppHandle<R>, state: WindowState) -> bool {
let Ok(monitors) = app_handle.available_monitors() else {
return true;
};
monitors.into_iter().any(|monitor| monitor_intersects_window(&monitor, state))
}
fn monitor_intersects_window(monitor: &Monitor, state: WindowState) -> bool {
let scale_factor = monitor.scale_factor();
let position = monitor.position().to_logical::<f64>(scale_factor);
let size = monitor.size().to_logical::<f64>(scale_factor);
let left = position.x;
let right = position.x + size.width;
let top = position.y;
let bottom = position.y + size.height;
[
(state.x, state.y),
(state.x + state.width, state.y),
(state.x, state.y + state.height),
(state.x + state.width, state.y + state.height),
]
.into_iter()
.any(|(x, y)| x >= left && x < right && y >= top && y < bottom)
}
+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 }
} }
} }
+26 -8
View File
@@ -7,7 +7,7 @@ use log::info;
use r2d2::Pool; use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager; use r2d2_sqlite::SqliteConnectionManager;
use std::fs::create_dir_all; use std::fs::create_dir_all;
use std::path::Path; use std::path::{Path, PathBuf};
use std::sync::mpsc; use std::sync::mpsc;
use std::time::Duration; use std::time::Duration;
@@ -22,6 +22,19 @@ pub mod query_manager;
pub mod render; pub mod render;
pub mod util; pub mod util;
fn sqlite_file_manager(path: impl Into<PathBuf>) -> SqliteConnectionManager {
SqliteConnectionManager::file(path.into()).with_init(|conn| {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.busy_timeout(Duration::from_millis(5000))
})
}
fn sqlite_memory_manager() -> SqliteConnectionManager {
SqliteConnectionManager::memory()
.with_init(|conn| conn.busy_timeout(Duration::from_millis(5000)))
}
/// Initialize the database managers for standalone (non-Tauri) usage. /// Initialize the database managers for standalone (non-Tauri) usage.
/// ///
/// Returns a tuple of (QueryManager, BlobManager, event_receiver). /// Returns a tuple of (QueryManager, BlobManager, event_receiver).
@@ -41,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 = SqliteConnectionManager::file(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()))?;
@@ -55,9 +72,10 @@ pub fn init_standalone(
info!("Initializing blobs database {blob_path:?}"); info!("Initializing blobs database {blob_path:?}");
// Blob database pool // Blob database pool
let blob_manager = SqliteConnectionManager::file(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()))?;
@@ -75,7 +93,7 @@ pub fn init_standalone(
/// Useful for testing and CI environments. /// Useful for testing and CI environments.
pub fn init_in_memory() -> Result<(QueryManager, BlobManager, mpsc::Receiver<ModelPayload>)> { pub fn init_in_memory() -> Result<(QueryManager, BlobManager, mpsc::Receiver<ModelPayload>)> {
// Main database pool // Main database pool
let manager = SqliteConnectionManager::memory(); let manager = sqlite_memory_manager();
let pool = Pool::builder() let pool = Pool::builder()
.max_size(1) // In-memory DB doesn't support multiple connections .max_size(1) // In-memory DB doesn't support multiple connections
.build(manager) .build(manager)
@@ -84,7 +102,7 @@ pub fn init_in_memory() -> Result<(QueryManager, BlobManager, mpsc::Receiver<Mod
migrate_db(&pool)?; migrate_db(&pool)?;
// Blob database pool // Blob database pool
let blob_manager = SqliteConnectionManager::memory(); let blob_manager = sqlite_memory_manager();
let blob_pool = Pool::builder() let blob_pool = Pool::builder()
.max_size(1) .max_size(1)
.build(blob_manager) .build(blob_manager)
+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");
+1 -4
View File
@@ -29,10 +29,7 @@ pub async fn delete_and_uninstall(
let db = query_manager.connect(); let db = query_manager.connect();
db.delete_plugin_by_id(plugin_id, &update_source)? db.delete_plugin_by_id(plugin_id, &update_source)?
}; };
if let Err(err) = plugin_manager if let Err(err) = plugin_manager.uninstall(plugin_context, plugin.directory.as_str()).await {
.uninstall(plugin_context, plugin.directory.as_str())
.await
{
if !matches!(err, PluginNotFoundErr(_)) { if !matches!(err, PluginNotFoundErr(_)) {
return Err(err); return Err(err);
} }
+10 -10
View File
@@ -684,6 +684,7 @@ pub async fn send_http_request<T: TemplateCallback>(
} }
})?; })?;
let body_path = params.response_dir.join(&response.id); let body_path = params.response_dir.join(&response.id);
let response_body_path = body_path.to_string_lossy().to_string();
let connected_response = HttpResponse { let connected_response = HttpResponse {
state: HttpResponseState::Connected, state: HttpResponseState::Connected,
elapsed_headers: headers_elapsed, elapsed_headers: headers_elapsed,
@@ -693,7 +694,7 @@ pub async fn send_http_request<T: TemplateCallback>(
remote_addr: http_response.remote_addr.clone(), remote_addr: http_response.remote_addr.clone(),
version: http_response.version.clone(), version: http_response.version.clone(),
elapsed_dns: dns_elapsed.load(Ordering::Relaxed), elapsed_dns: dns_elapsed.load(Ordering::Relaxed),
body_path: Some(body_path.to_string_lossy().to_string()), body_path: Some(response_body_path.clone()),
content_length: http_response.content_length.map(u64_to_i32), content_length: http_response.content_length.map(u64_to_i32),
headers: http_response headers: http_response
.headers .headers
@@ -724,6 +725,8 @@ pub async fn send_http_request<T: TemplateCallback>(
let mut body_stream = let mut body_stream =
http_response.into_body_stream().map_err(SendHttpRequestError::ReadResponseBody)?; http_response.into_body_stream().map_err(SendHttpRequestError::ReadResponseBody)?;
let mut response_body = Vec::new(); let mut response_body = Vec::new();
let mut read_buf = vec![0; 64 * 1024];
let collect_response_body = !persist_response && params.emit_response_body_chunks_to.is_none();
let mut body_read_error = None; let mut body_read_error = None;
let mut written_bytes: usize = 0; let mut written_bytes: usize = 0;
let mut last_progress_update = started_at; let mut last_progress_update = started_at;
@@ -740,12 +743,12 @@ pub async fn send_http_request<T: TemplateCallback>(
_ = cancelled_rx.changed() => { _ = cancelled_rx.changed() => {
None None
} }
result = body_stream.read_buf(&mut response_body) => { result = body_stream.read(&mut read_buf) => {
Some(result) Some(result)
} }
} }
} else { } else {
Some(body_stream.read_buf(&mut response_body).await) Some(body_stream.read(&mut read_buf).await)
}; };
let Some(read_result) = read_result else { let Some(read_result) = read_result else {
@@ -756,17 +759,14 @@ pub async fn send_http_request<T: TemplateCallback>(
Ok(0) => break, Ok(0) => break,
Ok(n) => { Ok(n) => {
written_bytes += n; written_bytes += n;
let start_idx = response_body.len() - n; let chunk = &read_buf[..n];
let chunk = &response_body[start_idx..];
file.write_all(chunk).await.map_err(|source| { file.write_all(chunk).await.map_err(|source| {
SendHttpRequestError::WriteResponseBody { path: body_path.clone(), source } SendHttpRequestError::WriteResponseBody { path: body_path.clone(), source }
})?; })?;
file.flush().await.map_err(|source| SendHttpRequestError::WriteResponseBody {
path: body_path.clone(),
source,
})?;
if let Some(tx) = params.emit_response_body_chunks_to.as_ref() { if let Some(tx) = params.emit_response_body_chunks_to.as_ref() {
let _ = tx.send(chunk.to_vec()); let _ = tx.send(chunk.to_vec());
} else if collect_response_body {
response_body.extend_from_slice(chunk);
} }
let now = Instant::now(); let now = Instant::now();
@@ -854,7 +854,7 @@ pub async fn send_http_request<T: TemplateCallback>(
let compressed_length = http_response.content_length.unwrap_or(written_bytes as u64); let compressed_length = http_response.content_length.unwrap_or(written_bytes as u64);
let final_response = HttpResponse { let final_response = HttpResponse {
body_path: Some(body_path.to_string_lossy().to_string()), body_path: Some(response_body_path),
content_length: Some(usize_to_i32(written_bytes)), content_length: Some(usize_to_i32(written_bytes)),
content_length_compressed: Some(u64_to_i32(compressed_length)), content_length_compressed: Some(u64_to_i32(compressed_length)),
elapsed: duration_to_i32(started_at.elapsed()), elapsed: duration_to_i32(started_at.elapsed()),
+127 -60
View File
@@ -80,10 +80,10 @@
"devDependencies": { "devDependencies": {
"@rolldown/plugin-babel": "^0.2.3", "@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/postcss": "^4.3.2", "@tailwindcss/postcss": "^4.3.2",
"@tauri-apps/cli": "^2.11.1", "@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",
@@ -4143,6 +4143,72 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.2", "version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
@@ -4458,9 +4524,10 @@
} }
}, },
"node_modules/@tauri-apps/cli": { "node_modules/@tauri-apps/cli": {
"version": "2.11.1", "name": "@tauri-apps/cli-cef",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.1.tgz", "version": "3.0.0-alpha.6",
"integrity": "sha512-rpEbaJ/HzNb6fwsquwoAbq29/Vt4gADhS423A8fdkwL4edJ0wZmoB8ar7O6JPDL834MUKOCm/rrJ7c9oAaEaYQ==", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-cef/-/cli-cef-3.0.0-alpha.6.tgz",
"integrity": "sha512-4Y52FZZuK6rpDDsJgxMp5q85QpQjf7Yo5IvLZs/CUBaIuMkGBDguDRcfkxhMJlu9qM/cLlBRW27OtqNdOW730w==",
"dev": true, "dev": true,
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"bin": { "bin": {
@@ -4474,23 +4541,23 @@
"url": "https://opencollective.com/tauri" "url": "https://opencollective.com/tauri"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.11.1", "@tauri-apps/cli-cef-darwin-arm64": "3.0.0-alpha.6",
"@tauri-apps/cli-darwin-x64": "2.11.1", "@tauri-apps/cli-cef-darwin-x64": "3.0.0-alpha.6",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.1", "@tauri-apps/cli-cef-linux-arm-gnueabihf": "3.0.0-alpha.6",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.1", "@tauri-apps/cli-cef-linux-arm64-gnu": "3.0.0-alpha.6",
"@tauri-apps/cli-linux-arm64-musl": "2.11.1", "@tauri-apps/cli-cef-linux-arm64-musl": "3.0.0-alpha.6",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.1", "@tauri-apps/cli-cef-linux-riscv64-gnu": "3.0.0-alpha.6",
"@tauri-apps/cli-linux-x64-gnu": "2.11.1", "@tauri-apps/cli-cef-linux-x64-gnu": "3.0.0-alpha.6",
"@tauri-apps/cli-linux-x64-musl": "2.11.1", "@tauri-apps/cli-cef-linux-x64-musl": "3.0.0-alpha.6",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.1", "@tauri-apps/cli-cef-win32-arm64-msvc": "3.0.0-alpha.6",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.1", "@tauri-apps/cli-cef-win32-ia32-msvc": "3.0.0-alpha.6",
"@tauri-apps/cli-win32-x64-msvc": "2.11.1" "@tauri-apps/cli-cef-win32-x64-msvc": "3.0.0-alpha.6"
} }
}, },
"node_modules/@tauri-apps/cli-darwin-arm64": { "node_modules/@tauri-apps/cli-cef-darwin-arm64": {
"version": "2.11.1", "version": "3.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-cef-darwin-arm64/-/cli-cef-darwin-arm64-3.0.0-alpha.6.tgz",
"integrity": "sha512-6eEKMBXsQPCuM1EmvrjT2+aBuxWQuFdKdW8pzNuNQtpq45nEEpBlD5gr8pUeAyOU1DQKlkFaEc/MPBxb/Pfjtg==", "integrity": "sha512-j6XHXCku1q82SXSXlQdzT7MbgOBX/ziOD+Z4c+B5D51zsaP3h6kcefpMzs71n9ZUegdw6AXpZpk+c+knOh2trQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -4504,10 +4571,10 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/cli-darwin-x64": { "node_modules/@tauri-apps/cli-cef-darwin-x64": {
"version": "2.11.1", "version": "3.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-cef-darwin-x64/-/cli-cef-darwin-x64-3.0.0-alpha.6.tgz",
"integrity": "sha512-LQUO7exfRWjWALNhetph5guWpMeHphRpokOLk0OIbTTExaNwJNFu3I4vb+CCM/4G/QGoZe/5XikZOJdNEFP1ig==", "integrity": "sha512-zBNxrXomAXSZPCYspOjvoi8aVAnJtkSOy3cVD+9aYDpf2zZL4R7VQXcY4M76PvqbGUmCOXT9ztzxV2MyWnBoDQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -4521,10 +4588,10 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { "node_modules/@tauri-apps/cli-cef-linux-arm-gnueabihf": {
"version": "2.11.1", "version": "3.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-cef-linux-arm-gnueabihf/-/cli-cef-linux-arm-gnueabihf-3.0.0-alpha.6.tgz",
"integrity": "sha512-5i/awiBCRRhOUG8yjn0fMHXIWD5Ez8eEk5LtvOxyQrKuJkRaZDvnbIjZbE183blAwkoA4xN3aO/prJiqscl02Q==", "integrity": "sha512-EcNXX9xsAZj0BuRXBwliR35XML6B5aIbgH8blbEGAr3pDaSCBnNyS/dpORgSdCKuysLidBT7K8fer5mMtbSoUQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -4538,10 +4605,10 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm64-gnu": { "node_modules/@tauri-apps/cli-cef-linux-arm64-gnu": {
"version": "2.11.1", "version": "3.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-cef-linux-arm64-gnu/-/cli-cef-linux-arm64-gnu-3.0.0-alpha.6.tgz",
"integrity": "sha512-9LrwDw3S9Fygtw/Q6WDhOP+3svJRGAsejeE+GKrc0eO1ThMVhwi2LL6hw4dlKw93IfS7VY1G19sWGxJ/NcU4nA==", "integrity": "sha512-/AwSRWuE5UKGumrXdpRnIBDWVjFAMEMkSFbB/vpFx9PogQ/A0R+i6+zuBh7JPV7FKmyRzVJuNxZ8EmZ319Ir8g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -4555,10 +4622,10 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm64-musl": { "node_modules/@tauri-apps/cli-cef-linux-arm64-musl": {
"version": "2.11.1", "version": "3.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-cef-linux-arm64-musl/-/cli-cef-linux-arm64-musl-3.0.0-alpha.6.tgz",
"integrity": "sha512-mNA5dbbqPqDUdTIwdUYYuhO2GvIe9UnB2r0VU2njxBOS3Opbx4gKNC5yP0Iu4rYmEmqdlwry9VzGZQ3wq9dyFg==", "integrity": "sha512-OZ5gjROy4/TlaYQ33roxQYcFIFJH+HZMER9gwTkWxfYUC1gTLLhsBeIYUZzWqm1NQiIc07likg081wsHkCRrWw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -4572,10 +4639,10 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": { "node_modules/@tauri-apps/cli-cef-linux-riscv64-gnu": {
"version": "2.11.1", "version": "3.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-cef-linux-riscv64-gnu/-/cli-cef-linux-riscv64-gnu-3.0.0-alpha.6.tgz",
"integrity": "sha512-fZj3Gwq+6fUs305T5WQiD5iSGJw+j/4w/HGmk4sHDAcy+rp9zU5eaxB7nOyz5/I/nkNAuKPqfp6uIbiUBXkBCw==", "integrity": "sha512-r9TQKmN4KfeXiiusn0cppRxljKsf96vTsC1ehYi4beh+6JlmGR4pK7eb+LuOGJscnjurAA9b153fpeYO/O1PTw==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -4589,10 +4656,10 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/cli-linux-x64-gnu": { "node_modules/@tauri-apps/cli-cef-linux-x64-gnu": {
"version": "2.11.1", "version": "3.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-cef-linux-x64-gnu/-/cli-cef-linux-x64-gnu-3.0.0-alpha.6.tgz",
"integrity": "sha512-XFxGxOvHM7jjeD6ozCKdGfhzJ7lERYDGZl1/Kb4fsvchaJsfLJ981TlyTG8Qy/gFq+f5GitH3bfrX9JAkjPEyw==", "integrity": "sha512-9rrMBVlqbNlp8nTJuZIDQ7iKFwGaVMRCNPIsUIZ56DiaCLoP4po+yvR48n+TyRPZYp1sb3q2Nr/5/zI2q/jFAQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -4606,10 +4673,10 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/cli-linux-x64-musl": { "node_modules/@tauri-apps/cli-cef-linux-x64-musl": {
"version": "2.11.1", "version": "3.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-cef-linux-x64-musl/-/cli-cef-linux-x64-musl-3.0.0-alpha.6.tgz",
"integrity": "sha512-d5C2/Zm+68v7R9wTuTCjRQEVrWjcdMkJBZ1+rXse+QdMMlTB9+u9PDNDLw9PQflWxYLaYZ7tjxxL9Nb9II6PbA==", "integrity": "sha512-pcFL71xscjEM/03lrH/GiqIr4SwBWlbvaRh8GxDePJqqrQojNn+kTzVvqowaXVbTrNr5rbHbXzY4rC/5/1Tbsw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -4623,10 +4690,10 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/cli-win32-arm64-msvc": { "node_modules/@tauri-apps/cli-cef-win32-arm64-msvc": {
"version": "2.11.1", "version": "3.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-cef-win32-arm64-msvc/-/cli-cef-win32-arm64-msvc-3.0.0-alpha.6.tgz",
"integrity": "sha512-YdeVWFAR1pTXzUU6NLstPq4G6OLxuDrXCXEBdmBH+5EZIDXUx0D2kJlz3+YjpazkKvAzYpgziTsyRagls0OfRQ==", "integrity": "sha512-IrMgveBs0HNO7C7BaZCUHGtO9jPbop/iU/5XWNqBx7uYHJL565+yIlpzWPoXhT3qbt8h8FQFO6SU3N+zfvLipA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -4640,10 +4707,10 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/cli-win32-ia32-msvc": { "node_modules/@tauri-apps/cli-cef-win32-ia32-msvc": {
"version": "2.11.1", "version": "3.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-cef-win32-ia32-msvc/-/cli-cef-win32-ia32-msvc-3.0.0-alpha.6.tgz",
"integrity": "sha512-VBGkuH0eB9K9LLSMv361Gzr5Ou72sCS4+ztpmkWEQ+wd/amhcYOsf3X6qn1RJZDzIhiOYHJEOysZUC3baD01rA==", "integrity": "sha512-qA/VaRdRwdMt9L2oDGNZPur108mUleKRUSCk6X8sVPcnPjzMfN4/aESeOKsW+zHXN/VhZbV3UieEg4IcYeI2RQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -4657,10 +4724,10 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/cli-win32-x64-msvc": { "node_modules/@tauri-apps/cli-cef-win32-x64-msvc": {
"version": "2.11.1", "version": "3.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-cef-win32-x64-msvc/-/cli-cef-win32-x64-msvc-3.0.0-alpha.6.tgz",
"integrity": "sha512-b3ORhIAKgp9ZYY+zBt7b7r0kLU2kjvyGF0+MS2SBym3emsweGPybEqocJcmtMuxyBhkOKHP4CiuEJEDuAlTx6A==", "integrity": "sha512-EzlhstDVyvHy3M5ieSoH3VBlikgqQS99vFEDKFoy16mwIWUrTG1LkoSI0mKbqmO8DWIEzeSV17XiCw2XEcYslQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
+2 -2
View File
@@ -113,10 +113,10 @@
"devDependencies": { "devDependencies": {
"@rolldown/plugin-babel": "^0.2.3", "@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/postcss": "^4.3.2", "@tailwindcss/postcss": "^4.3.2",
"@tauri-apps/cli": "^2.11.1", "@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",
+38 -1
View File
@@ -1,7 +1,10 @@
import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
export type Appearance = "light" | "dark"; export type Appearance = "light" | "dark";
const SYSTEM_APPEARANCE_CHANGE_EVENT = "system_appearance_change";
export function getCSSAppearance(): Appearance { export function getCSSAppearance(): Appearance {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
} }
@@ -11,6 +14,13 @@ export async function getWindowAppearance(): Promise<Appearance> {
return appearance ?? getCSSAppearance(); return appearance ?? getCSSAppearance();
} }
export function subscribeToCSSAppearanceChange(cb: (appearance: Appearance) => void): () => void {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const listener = () => cb(media.matches ? "dark" : "light");
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}
export function subscribeToWindowAppearanceChange( export function subscribeToWindowAppearanceChange(
cb: (appearance: Appearance) => void, cb: (appearance: Appearance) => void,
): () => void { ): () => void {
@@ -29,6 +39,22 @@ export function subscribeToWindowAppearanceChange(
return () => container.unsubscribe(); return () => container.unsubscribe();
} }
export function subscribeToSystemAppearanceChange(
cb: (appearance: Appearance) => void,
): () => void {
const container = {
unsubscribe: () => {},
};
void listen<Appearance>(SYSTEM_APPEARANCE_CHANGE_EVENT, (event) => {
cb(event.payload);
}).then((listener) => {
container.unsubscribe = listener;
});
return () => container.unsubscribe();
}
export function resolveAppearance( export function resolveAppearance(
preferredAppearance: Appearance, preferredAppearance: Appearance,
appearanceSetting: string, appearanceSetting: string,
@@ -40,5 +66,16 @@ export function resolveAppearance(
export function subscribeToPreferredAppearance(cb: (appearance: Appearance) => void) { export function subscribeToPreferredAppearance(cb: (appearance: Appearance) => void) {
cb(getCSSAppearance()); cb(getCSSAppearance());
void getWindowAppearance().then(cb); void getWindowAppearance().then(cb);
subscribeToWindowAppearanceChange(cb); return subscribeToPreferredAppearanceChange(cb);
}
export function subscribeToPreferredAppearanceChange(cb: (appearance: Appearance) => void) {
const unsubscribeCSS = subscribeToCSSAppearanceChange(cb);
const unsubscribeWindow = subscribeToWindowAppearanceChange(cb);
const unsubscribeSystem = subscribeToSystemAppearanceChange(cb);
return () => {
unsubscribeCSS();
unsubscribeWindow();
unsubscribeSystem();
};
} }
+3
View File
@@ -1,9 +1,12 @@
export type { Appearance } from "./appearance"; export type { Appearance } from "./appearance";
export { export {
subscribeToCSSAppearanceChange,
getCSSAppearance, getCSSAppearance,
getWindowAppearance, getWindowAppearance,
resolveAppearance, resolveAppearance,
subscribeToPreferredAppearance, subscribeToPreferredAppearance,
subscribeToPreferredAppearanceChange,
subscribeToSystemAppearanceChange,
subscribeToWindowAppearanceChange, subscribeToWindowAppearanceChange,
} from "./appearance"; } from "./appearance";
export { defaultDarkTheme, defaultLightTheme } from "./defaultThemes"; export { defaultDarkTheme, defaultLightTheme } from "./defaultThemes";
+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(
+2 -1
View File
@@ -70,7 +70,8 @@ const normalizedAdditionalArgs = [];
for (let i = 0; i < additionalArgs.length; i++) { for (let i = 0; i < additionalArgs.length; i++) {
const arg = additionalArgs[i]; const arg = additionalArgs[i];
if (arg === "--") { if (arg === "--") {
continue; normalizedAdditionalArgs.push(arg, ...additionalArgs.slice(i + 1));
break;
} }
if (arg === "--config" && i + 1 < additionalArgs.length) { if (arg === "--config" && i + 1 < additionalArgs.length) {
const value = additionalArgs[i + 1]; const value = additionalArgs[i + 1];