mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-05 20:41:58 +02:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b13d46d01 | |||
| 2c1cf5a13c | |||
| b332a0eba9 | |||
| f2972ee534 | |||
| 4cad671305 | |||
| ea33261e08 | |||
| 4ee080fa49 | |||
| c833aeba78 | |||
| eb2a2dd775 | |||
| 2ac33e351b | |||
| e52853cc2d | |||
| 851d0a26f0 | |||
| 78de83c754 | |||
| 9f3fd46d49 | |||
| 6c42b27edb | |||
| 44ae19ff64 | |||
| 23bac3cff5 | |||
| cc05fec59d | |||
| 1206d5889d | |||
| 273e9c184d | |||
| 3f9baca85e | |||
| 0497a54928 | |||
| 5db2008fae |
@@ -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
|
|
||||||
[](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".
|
|
||||||
@@ -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,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):
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
name: Claude Code
|
|
||||||
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
pull_request_review_comment:
|
|
||||||
types: [created]
|
|
||||||
issues:
|
|
||||||
types: [opened, assigned]
|
|
||||||
pull_request_review:
|
|
||||||
types: [submitted]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
claude:
|
|
||||||
if: |
|
|
||||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
|
||||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
issues: read
|
|
||||||
id-token: write
|
|
||||||
actions: read # Required for Claude to read CI results on PRs
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Run Claude Code
|
|
||||||
id: claude
|
|
||||||
uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
|
|
||||||
# This is an optional setting that allows Claude to read CI results on PRs
|
|
||||||
additional_permissions: |
|
|
||||||
actions: read
|
|
||||||
|
|
||||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
|
||||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
|
||||||
|
|
||||||
# Optional: Add claude_args to customize behavior and configuration
|
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
||||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
|
||||||
@@ -30,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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
File diff suppressed because it is too large
Load Diff
+12
-2
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
|
mod window_state;
|
||||||
|
|
||||||
pub mod window;
|
pub mod window;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-25
@@ -511,7 +511,10 @@ pub async fn send_http_request<T: TemplateCallback>(
|
|||||||
.map_err(SendHttpRequestError::PrepareSendableRequest)?;
|
.map_err(SendHttpRequestError::PrepareSendableRequest)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let request_content_length = sendable_body_length(sendable_request.body.as_ref());
|
let request_content_length = match sendable_request.body.as_ref() {
|
||||||
|
Some(SendableBody::Bytes(_)) => sendable_body_length(sendable_request.body.as_ref()),
|
||||||
|
Some(SendableBody::Stream { .. }) | None => None,
|
||||||
|
};
|
||||||
let mut response = params.existing_response.unwrap_or_default();
|
let mut response = params.existing_response.unwrap_or_default();
|
||||||
response.request_id = params.request.id.clone();
|
response.request_id = params.request.id.clone();
|
||||||
response.workspace_id = params.request.workspace_id.clone();
|
response.workspace_id = params.request.workspace_id.clone();
|
||||||
@@ -684,6 +687,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 +697,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 +728,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 +746,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 +762,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();
|
||||||
@@ -811,16 +814,6 @@ pub async fn send_http_request<T: TemplateCallback>(
|
|||||||
})?;
|
})?;
|
||||||
drop(body_stream);
|
drop(body_stream);
|
||||||
|
|
||||||
if let Some(task) = request_body_capture_task.take() {
|
|
||||||
match task.await {
|
|
||||||
Ok(Ok(total)) => {
|
|
||||||
response.request_content_length = Some(usize_to_i32(total));
|
|
||||||
}
|
|
||||||
Ok(Err(err)) => request_body_capture_error = Some(err),
|
|
||||||
Err(err) => request_body_capture_error = Some(err.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(err) = request_body_capture_error.take() {
|
if let Some(err) = request_body_capture_error.take() {
|
||||||
response.error = Some(append_error_message(
|
response.error = Some(append_error_message(
|
||||||
response.error.take(),
|
response.error.take(),
|
||||||
@@ -828,10 +821,6 @@ pub async fn send_http_request<T: TemplateCallback>(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(join_err) = event_handle.await {
|
|
||||||
warn!("Failed to join response event task: {}", join_err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(err) = body_read_error {
|
if let Some(err) = body_read_error {
|
||||||
if persist_response {
|
if persist_response {
|
||||||
let _ = persist_response_error(
|
let _ = persist_response_error(
|
||||||
@@ -849,12 +838,22 @@ pub async fn send_http_request<T: TemplateCallback>(
|
|||||||
cookie_jar.as_mut(),
|
cookie_jar.as_mut(),
|
||||||
cookie_behavior.store.as_ref(),
|
cookie_behavior.store.as_ref(),
|
||||||
)?;
|
)?;
|
||||||
|
if let Some(task) = request_body_capture_task.take() {
|
||||||
|
match task.await {
|
||||||
|
Ok(Ok(_)) => {}
|
||||||
|
Ok(Err(err)) => warn!("Failed to store request body after response error: {err}"),
|
||||||
|
Err(err) => warn!("Failed to join request body capture task: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(join_err) = event_handle.await {
|
||||||
|
warn!("Failed to join response event task: {}", join_err);
|
||||||
|
}
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
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()),
|
||||||
@@ -875,6 +874,49 @@ pub async fn send_http_request<T: TemplateCallback>(
|
|||||||
|
|
||||||
persist_cookie_jar(params.query_manager, cookie_jar.as_mut(), cookie_behavior.store.as_ref())?;
|
persist_cookie_jar(params.query_manager, cookie_jar.as_mut(), cookie_behavior.store.as_ref())?;
|
||||||
|
|
||||||
|
// Request-body history can be much larger than the response. It should not keep the
|
||||||
|
// response in a loading state after the network/response-body work has completed.
|
||||||
|
if let Some(task) = request_body_capture_task.take() {
|
||||||
|
let mut update_response = false;
|
||||||
|
match task.await {
|
||||||
|
Ok(Ok(total)) => {
|
||||||
|
let total = Some(usize_to_i32(total));
|
||||||
|
if response.request_content_length != total {
|
||||||
|
response.request_content_length = total;
|
||||||
|
update_response = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
response.error = Some(append_error_message(
|
||||||
|
response.error.take(),
|
||||||
|
format!("Request succeeded but failed to store request body: {err}"),
|
||||||
|
));
|
||||||
|
update_response = true;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
response.error = Some(append_error_message(
|
||||||
|
response.error.take(),
|
||||||
|
format!("Request succeeded but failed to store request body: {err}"),
|
||||||
|
));
|
||||||
|
update_response = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if update_response && persist_response {
|
||||||
|
response = params
|
||||||
|
.query_manager
|
||||||
|
.connect()
|
||||||
|
.upsert_http_response(&response, ¶ms.update_source, params.blob_manager)
|
||||||
|
.map_err(SendHttpRequestError::PersistResponse)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeline events are useful history, but they should not keep the response in a loading state
|
||||||
|
// after the network/response-body work has completed.
|
||||||
|
if let Err(join_err) = event_handle.await {
|
||||||
|
warn!("Failed to join response event task: {}", join_err);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(SendHttpRequestResult { rendered_request, response, response_body })
|
Ok(SendHttpRequestResult { rendered_request, response, response_body })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+127
-60
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] = {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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];
|
||||||
|
|||||||
Reference in New Issue
Block a user