Compare commits

..

14 Commits

Author SHA1 Message Date
Gregory Schier 1dd7e728ff Add contribution policy workflow 2026-06-30 13:34:11 -07:00
Gregory Schier 3a349bccfe Fix wording 2026-06-30 10:28:39 -07:00
Gregory Schier 13a667a9b1 Add commercial use nudge banners (#478)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Nguyễn Huỳnh Anh Khoa <113995598+anhkhoakz@users.noreply.github.com>
Co-authored-by: startsevdenis <mail@startsevds.ru>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 10:23:13 -07:00
Gregory Schier 420c6e2c4a Tweak message size placeholder and notifications 2026-06-30 09:44:11 -07:00
Gregory Schier bbdfbcb9ca Add configurable gRPC and WebSocket message size limit (#487) 2026-06-30 09:14:41 -07:00
dependabot[bot] d1e6f8fb33 Bump js-cookie and react-use (#488)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:33:14 -07:00
dependabot[bot] 930a816f42 Bump ws from 8.20.1 to 8.21.0 (#480)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:54 -07:00
dependabot[bot] ec0143aa93 Bump hono from 4.12.18 to 4.12.25 (#479)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:47 -07:00
dependabot[bot] 3cc54dea22 Bump vite-plus from 0.1.20 to 0.1.24 (#473)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:41 -07:00
dependabot[bot] a8fb144c09 Bump esbuild and tsx (#472)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:33 -07:00
dependabot[bot] 6813fa8bf2 Bump shell-quote from 1.8.3 to 1.8.4 (#471)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:25 -07:00
dependabot[bot] cf7de26a2e Bump tar from 0.4.45 to 0.4.46 (#469)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:16 -07:00
dependabot[bot] 8676272657 Bump qs from 6.14.1 to 6.15.2 (#467)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:10 -07:00
startsevdenis c3aecfdc0c fix: increase tonic gRPC max_decoding_message_size to 64MB 2026-06-29 16:06:02 -07:00
56 changed files with 2672 additions and 910 deletions
@@ -0,0 +1,566 @@
const COMMENT_MARKER = "<!-- yaak-contribution-policy -->";
const MAINTAINER_LOGINS = new Set(["gschier"]);
const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
const MAINTAINER_PERMISSIONS = new Set(["admin", "maintain", "write"]);
const LARGE_DIFF_CHANGED_FILES = 20;
const LARGE_DIFF_CHANGED_LINES = 800;
const LABELS = {
accepted: {
name: "contribution: accepted",
color: "0E8A16",
description: "Community PR appears to match Yaak's contribution policy.",
},
approvedFeedback: {
name: "contribution: approved feedback",
color: "5319E7",
description: "Community PR links an approved feedback item.",
},
needsTemplate: {
name: "contribution: needs template",
color: "D93F0B",
description: "Community PR needs a completed pull request template.",
},
needsApproval: {
name: "contribution: needs approval",
color: "B60205",
description: "Community PR needs an approved feedback item before review.",
},
largeDiff: {
name: "contribution: large diff",
color: "FBCA04",
description:
"Community PR has a larger-than-usual diff for a small-scope contribution.",
},
};
const MANAGED_LABEL_NAMES = Object.values(LABELS).map((label) => label.name);
const CHECKBOXES = {
smallScope: "This PR is a bug fix or small-scope improvement.",
approvedFeedback:
"If this PR is not a bug fix or small-scope improvement, I linked an approved feedback item below.",
readContributing:
"I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).",
testedLocally: "I tested this change locally.",
testsUpdated: "I added or updated tests when reasonable.",
};
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function normalizeBody(body) {
return (body || "").replace(/\r\n/g, "\n");
}
function stripComments(value) {
return value.replace(/<!--[\s\S]*?-->/g, "").trim();
}
function getSection(body, heading) {
const pattern = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, "gim");
const match = pattern.exec(body);
if (match == null) {
return null;
}
const rest = body.slice(match.index + match[0].length);
const nextHeadingIndex = rest.search(/^##\s+/m);
return nextHeadingIndex === -1 ? rest : rest.slice(0, nextHeadingIndex);
}
function hasMeaningfulText(value) {
return stripComments(value || "").length > 0;
}
function checkboxState(body, label) {
const flexibleLabel = escapeRegExp(label).replace(/\\ /g, "\\s+");
const pattern = new RegExp(
`^\\s*[-*]\\s*\\[([ xX])\\]\\s*${flexibleLabel}\\s*$`,
"im",
);
const match = body.match(pattern);
if (match == null) {
return null;
}
return match[1].toLowerCase() === "x";
}
function findFeedbackUrl(body) {
return (
body.match(
/https?:\/\/(?:www\.)?(?:yaak\.app\/feedback|feedback\.yaak\.app)\/[^\s)>\]]+/i,
)?.[0] ?? null
);
}
function analyzePullRequest(pr) {
const body = normalizeBody(pr.body);
const states = Object.fromEntries(
Object.entries(CHECKBOXES).map(([key, label]) => [
key,
checkboxState(body, label),
]),
);
const sectionCount = ["Summary", "Submission", "Related"].filter(
(heading) => getSection(body, heading) != null,
).length;
const checkboxCount = Object.values(states).filter(
(state) => state != null,
).length;
const templateUsed = sectionCount >= 2 && checkboxCount >= 3;
const blockers = [];
const totalChangedLines =
Number(pr.additions || 0) + Number(pr.deletions || 0);
const changedFiles = Number(pr.changed_files || 0);
const largeDiff =
changedFiles > LARGE_DIFF_CHANGED_FILES ||
totalChangedLines > LARGE_DIFF_CHANGED_LINES;
if (!templateUsed) {
blockers.push({
label: LABELS.needsTemplate.name,
message:
"Update the PR description with the repository pull request template.",
});
} else {
const summary = getSection(body, "Summary");
const hasSummary = hasMeaningfulText(summary);
const feedbackUrl = findFeedbackUrl(body);
const smallScope = states.smallScope === true;
const approvedFeedback = states.approvedFeedback === true;
if (!hasSummary) {
blockers.push({
label: LABELS.needsTemplate.name,
message: "Add a short summary describing the bug fix or improvement.",
});
}
if (smallScope && approvedFeedback) {
blockers.push({
label: LABELS.needsTemplate.name,
message:
"Choose either the small-scope checkbox or the approved-feedback checkbox, not both.",
});
} else if (!smallScope && !approvedFeedback) {
blockers.push({
label: LABELS.needsTemplate.name,
message:
"Check whether this is a bug fix or small-scope improvement, or confirm that an approved feedback item is linked.",
});
} else if (approvedFeedback && feedbackUrl == null) {
blockers.push({
label: LABELS.needsApproval.name,
message:
"Link the approved feedback item where contribution approval was explicitly stated.",
});
}
if (states.readContributing !== true) {
blockers.push({
label: LABELS.needsTemplate.name,
message: "Confirm that `CONTRIBUTING.md` was read and followed.",
});
}
if (states.testedLocally !== true) {
blockers.push({
label: LABELS.needsTemplate.name,
message: "Confirm that the change was tested locally.",
});
}
if (states.testsUpdated !== true) {
blockers.push({
label: LABELS.needsTemplate.name,
message: "Confirm that tests were added or updated when reasonable.",
});
}
}
const desiredLabels = new Set(blockers.map((blocker) => blocker.label));
if (blockers.length === 0) {
desiredLabels.add(
states.approvedFeedback
? LABELS.approvedFeedback.name
: LABELS.accepted.name,
);
}
if (largeDiff) {
desiredLabels.add(LABELS.largeDiff.name);
}
return {
blockers,
changedFiles,
desiredLabels: [...desiredLabels],
largeDiff,
templateUsed,
totalChangedLines,
};
}
function buildBlockingComment(analysis) {
const lines = [
COMMENT_MARKER,
"Thanks for the PR. Yaak currently accepts community PRs for bug fixes and small-scope improvements, plus larger changes that link an approved feedback item from https://yaak.app/feedback.",
"",
"This PR cannot be accepted yet. Please update the PR description to address:",
"",
...analysis.blockers.map((blocker) => `- ${blocker.message}`),
];
if (analysis.largeDiff) {
lines.push(
"",
`This PR also changes ${analysis.changedFiles} files and ${analysis.totalChangedLines} lines, so it has been labeled as a large diff. That label is advisory, but maintainers may ask for the scope to be reduced.`,
);
}
lines.push(
"",
"I did not overwrite the PR body, since that can remove useful context. Editing the description directly is the safest way to keep your notes while completing the template.",
);
return lines.join("\n");
}
function summarizeResult({ pr, analysis, skipped, skipReason }) {
if (skipped) {
return `#${pr.number} ${pr.title} - skipped (${skipReason})`;
}
const status =
analysis.blockers.length > 0
? `blocked: ${analysis.blockers.map((blocker) => blocker.message).join("; ")}`
: "accepted";
const labels =
analysis.desiredLabels.length > 0
? analysis.desiredLabels.join(", ")
: "none";
return `#${pr.number} ${pr.title} - ${status}; labels: ${labels}`;
}
async function isOfficialMaintainer({ github, owner, repo, pr }) {
if (MAINTAINER_LOGINS.has(pr.user.login)) {
return true;
}
if (MAINTAINER_ASSOCIATIONS.has(pr.author_association)) {
return true;
}
try {
const response = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: pr.user.login,
});
return MAINTAINER_PERMISSIONS.has(response.data.permission);
} catch (error) {
if (error.status === 404) {
return false;
}
throw error;
}
}
async function ensureManagedLabels({ github, owner, repo }) {
for (const label of Object.values(LABELS)) {
try {
await github.rest.issues.getLabel({
owner,
repo,
name: label.name,
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
await github.rest.issues.createLabel({
owner,
repo,
name: label.name,
color: label.color,
description: label.description,
});
}
}
}
async function syncLabels({ github, owner, repo, issueNumber, desiredLabels }) {
const desired = new Set(desiredLabels);
await ensureManagedLabels({ github, owner, repo });
for (const labelName of MANAGED_LABEL_NAMES) {
if (desired.has(labelName)) {
continue;
}
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: issueNumber,
name: labelName,
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
}
if (desired.size > 0) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: issueNumber,
labels: [...desired],
});
}
}
async function findPolicyComment({ github, owner, repo, issueNumber }) {
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
return comments.find(
(comment) =>
comment.user.type === "Bot" && comment.body?.includes(COMMENT_MARKER),
);
}
async function upsertPolicyComment({ github, owner, repo, issueNumber, body }) {
const existingComment = await findPolicyComment({
github,
owner,
repo,
issueNumber,
});
if (existingComment == null) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body,
});
return;
}
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existingComment.id,
body,
});
}
async function deletePolicyComment({ github, owner, repo, issueNumber }) {
const existingComment = await findPolicyComment({
github,
owner,
repo,
issueNumber,
});
if (existingComment == null) {
return;
}
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: existingComment.id,
});
}
async function checkPullRequest({
github,
core,
owner,
repo,
pullNumber,
dryRun,
}) {
const response = await github.rest.pulls.get({
owner,
repo,
pull_number: pullNumber,
});
const pr = response.data;
const issueNumber = pr.number;
if (pr.draft) {
core.notice(`Skipping contribution policy for draft PR #${pr.number}.`);
return {
blocked: false,
number: pr.number,
summary: summarizeResult({
pr,
skipped: true,
skipReason: "draft",
}),
skipped: true,
};
}
if (await isOfficialMaintainer({ github, owner, repo, pr })) {
core.notice(
`Skipping contribution policy for maintainer PR #${pr.number} from @${pr.user.login}.`,
);
if (!dryRun) {
await syncLabels({ github, owner, repo, issueNumber, desiredLabels: [] });
await deletePolicyComment({ github, owner, repo, issueNumber });
}
return {
blocked: false,
number: pr.number,
summary: summarizeResult({
pr,
skipped: true,
skipReason: `maintainer @${pr.user.login}`,
}),
skipped: true,
};
}
const analysis = analyzePullRequest(pr);
if (dryRun) {
const summary = summarizeResult({ pr, analysis });
core.notice(`[dry-run] ${summary}`);
return {
blocked: analysis.blockers.length > 0,
number: pr.number,
summary,
skipped: false,
};
}
await syncLabels({
github,
owner,
repo,
issueNumber,
desiredLabels: analysis.desiredLabels,
});
if (analysis.blockers.length > 0) {
await upsertPolicyComment({
github,
owner,
repo,
issueNumber,
body: buildBlockingComment(analysis),
});
return {
blocked: true,
number: pr.number,
summary: summarizeResult({ pr, analysis }),
skipped: false,
};
}
await deletePolicyComment({ github, owner, repo, issueNumber });
core.notice(`Contribution policy check passed for PR #${pr.number}.`);
return {
blocked: false,
number: pr.number,
summary: summarizeResult({ pr, analysis }),
skipped: false,
};
}
async function listOpenPullRequests({ github, owner, repo }) {
return github.paginate(github.rest.pulls.list, {
owner,
repo,
state: "open",
per_page: 100,
});
}
async function run({ github, context, core }) {
const { owner, repo } = context.repo;
const payloadPr = context.payload.pull_request;
const dryRun =
context.eventName === "workflow_dispatch" &&
context.payload.inputs?.dry_run !== "false";
const pullRequests =
payloadPr == null
? await listOpenPullRequests({ github, owner, repo })
: [payloadPr];
const results = [];
if (dryRun) {
core.notice("Running contribution policy in dry-run mode.");
}
for (const pr of pullRequests) {
results.push(
await checkPullRequest({
github,
core,
owner,
repo,
pullNumber: pr.number,
dryRun,
}),
);
}
await core.summary
.addHeading(`Contribution Policy ${dryRun ? "Dry Run" : "Results"}`)
.addTable([
[
{ data: "PR", header: true },
{ data: "Result", header: true },
],
...results.map((result) => [`#${result.number}`, result.summary]),
])
.write();
const blockedPullRequests = results.filter((result) => result.blocked);
if (blockedPullRequests.length > 0) {
if (dryRun) {
core.warning(
`Dry run found contribution policy failures for PR(s): ${blockedPullRequests
.map((result) => `#${result.number}`)
.join(", ")}`,
);
return;
}
core.setFailed(
`Contribution policy failed for PR(s): ${blockedPullRequests
.map((result) => `#${result.number}`)
.join(", ")}`,
);
}
}
module.exports = {
analyzePullRequest,
run,
};
+32
View File
@@ -0,0 +1,32 @@
name: Contribution Policy
on:
workflow_dispatch:
inputs:
dry_run:
description: Preview labels and comments without changing PRs
required: true
default: true
type: boolean
permissions:
contents: read
issues: write
pull-requests: read
jobs:
check:
name: Check contribution policy
runs-on: ubuntu-latest
steps:
- name: Checkout policy script
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check contribution policy
uses: actions/github-script@v7
with:
script: |
const { run } = require("./.github/scripts/check-contribution-policy.js");
await run({ github, context, core });
Generated
+9 -9
View File
@@ -215,7 +215,7 @@ dependencies = [
"objc2-foundation 0.3.1", "objc2-foundation 0.3.1",
"parking_lot", "parking_lot",
"percent-encoding", "percent-encoding",
"windows-sys 0.59.0", "windows-sys 0.52.0",
"wl-clipboard-rs", "wl-clipboard-rs",
"x11rb", "x11rb",
] ]
@@ -1151,7 +1151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [ dependencies = [
"lazy_static 1.5.0", "lazy_static 1.5.0",
"windows-sys 0.59.0", "windows-sys 0.48.0",
] ]
[[package]] [[package]]
@@ -1970,7 +1970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -6534,7 +6534,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.4.15", "linux-raw-sys 0.4.15",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -6547,7 +6547,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.9.4", "linux-raw-sys 0.9.4",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -7508,9 +7508,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]] [[package]]
name = "tar" name = "tar"
version = "0.4.45" version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [ dependencies = [
"filetime", "filetime",
"libc", "libc",
@@ -7988,7 +7988,7 @@ dependencies = [
"getrandom 0.3.3", "getrandom 0.3.3",
"once_cell", "once_cell",
"rustix 1.0.7", "rustix 1.0.7",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -9317,7 +9317,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.48.0",
] ]
[[package]] [[package]]
@@ -4,6 +4,7 @@ import { Banner, VStack } from "@yaakapp-internal/ui";
import { useState } from "react"; import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir"; import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { appInfo } from "../lib/appInfo"; import { appInfo } from "../lib/appInfo";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { showErrorToast } from "../lib/toast"; import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox"; import { Checkbox } from "./core/Checkbox";
@@ -89,6 +90,8 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
</Banner> </Banner>
)} )}
<CommercialUseBanner source="git-clone" title="Using Git for work?" />
<PlainInput <PlainInput
required required
label="Repository URL" label="Repository URL"
@@ -0,0 +1,130 @@
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
import { useCallback, useEffect, useRef, useState } from "react";
import { useKeyValue } from "../hooks/useKeyValue";
import { appInfo } from "../lib/appInfo";
import { pricingUrl } from "../lib/pricingUrl";
import { DismissibleBanner } from "./core/DismissibleBanner";
const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
const COMMERCIAL_USE_BANNER_MESSAGE =
"Personal use of Yaak is free. If youre using Yaak at work, please purchase a license.";
export function CommercialUseBanner({
source,
title,
}: {
source: string;
title: string;
}) {
const [visible, setVisible] = useState(false);
const snoozeStartedRef = useRef(false);
const {
isLoading: isSnoozeLoading,
set: setSnoozedAt,
value: snoozedAt,
} = useKeyValue<string | null>({
namespace: "global",
key: "commercial-use-banner-snoozed-at",
fallback: null,
});
useEffect(() => {
let canceled = false;
shouldShowCommercialUsePrompt()
.then((shouldShow) => {
if (!canceled) setVisible(shouldShow);
})
.catch(console.error);
return () => {
canceled = true;
};
}, [source]);
const snoozed = isSnoozed(snoozedAt, COMMERCIAL_USE_SNOOZE_MS);
const handleShow = useCallback(() => {
if (snoozeStartedRef.current || snoozed) {
return;
}
snoozeStartedRef.current = true;
setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() })).catch(console.error);
}, [setSnoozedAt, snoozed, source]);
if (!visible || isSnoozeLoading || snoozed) {
return null;
}
return (
<div className="w-full">
<DismissibleBanner
id={`commercial-use:${source}`}
color="info"
className="w-full"
onDismiss={() =>
setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() }))
}
onShow={handleShow}
actions={[
{
label: "Purchase License",
color: "info",
variant: "solid",
onClick: () => {
openCommercialUsePricing(source).catch(console.error);
},
},
]}
>
<div className="text-sm">
<p className="font-semibold text-text">{title}</p>
<p className="mt-0.5 text-text-subtle">{COMMERCIAL_USE_BANNER_MESSAGE}</p>
</div>
</DismissibleBanner>
</div>
);
}
async function shouldShowCommercialUsePrompt(): Promise<boolean> {
// Open-source builds omit the Rust license plugin, so never show commercial-use prompts there.
if (appInfo.featureLicense !== true) {
return false;
}
try {
const license = await invoke<LicenseCheckStatus>("plugin:yaak-license|check");
return license.status !== "active" && license.status !== "trialing";
} catch (err) {
console.log("Failed to check license before commercial-use prompt", err);
return true;
}
}
async function openCommercialUsePricing(source: string): Promise<void> {
await openUrl(pricingUrl(`app.commercial-use.${source}`)).catch(console.error);
}
function isSnoozed(value: string | null, ms: number): boolean {
if (value == null) return false;
try {
const snooze = JSON.parse(value) as { at?: unknown };
const at = typeof snooze.at === "string" ? snooze.at : null;
return isWithinMs(at, ms);
} catch {
// Older builds stored only the timestamp, so keep respecting that as a global snooze.
return isWithinMs(value, ms);
}
}
function isWithinMs(date: string | null, ms: number): boolean {
if (date == null) return false;
const time = new Date(date).getTime();
if (Number.isNaN(time)) return false;
return Date.now() - time < ms;
}
@@ -8,6 +8,7 @@ import slugify from "slugify";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace"; import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { pluralizeCount } from "../lib/pluralize"; import { pluralizeCount } from "../lib/pluralize";
import { invokeCmd } from "../lib/tauri"; import { invokeCmd } from "../lib/tauri";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox"; import { Checkbox } from "./core/Checkbox";
import { DetailsBanner } from "./core/DetailsBanner"; import { DetailsBanner } from "./core/DetailsBanner";
@@ -85,8 +86,10 @@ function ExportDataDialogContent({
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length; const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
const noneSelected = numSelected === 0; const noneSelected = numSelected === 0;
return ( return (
<div className="w-full grid grid-rows-[minmax(0,1fr)_auto]"> <div className="h-full w-full grid grid-rows-[minmax(0,1fr)_auto] overflow-hidden rounded-b-lg">
<VStack space={3} className="overflow-auto px-5 pb-6"> <VStack space={3} className="overflow-auto px-5 pb-6">
<CommercialUseBanner source="data-export" title="Exporting work data?" />
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight"> <table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
<thead> <thead>
<tr> <tr>
@@ -137,9 +140,9 @@ function ExportDataDialogContent({
/> />
</DetailsBanner> </DetailsBanner>
</VStack> </VStack>
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface-highlight py-2 border-t border-border-subtle"> <footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle">
<div> <div>
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtle"> <Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest">
Create Run Button Create Run Button
</Link> </Link>
</div> </div>
@@ -10,14 +10,17 @@ import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
import { useCallback } from "react"; import { useCallback } from "react";
import { openFolderSettings } from "../commands/openFolderSettings"; import { openFolderSettings } from "../commands/openFolderSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings"; import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { useAuthDropdownOptions } from "../hooks/useAuthTab";
import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig"; import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig";
import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication"; import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication";
import { useRenderTemplate } from "../hooks/useRenderTemplate"; import { useRenderTemplate } from "../hooks/useRenderTemplate";
import { resolvedModelName } from "../lib/resolvedModelName"; import { resolvedModelName } from "../lib/resolvedModelName";
import { Button } from "./core/Button";
import { Dropdown, type DropdownItem } from "./core/Dropdown"; import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { Input, type InputProps } from "./core/Input"; import { Input, type InputProps } from "./core/Input";
import { Link } from "./core/Link"; import { Link } from "./core/Link";
import { RadioDropdown } from "./core/RadioDropdown";
import { SegmentedControl } from "./core/SegmentedControl"; import { SegmentedControl } from "./core/SegmentedControl";
import { DynamicForm } from "./DynamicForm"; import { DynamicForm } from "./DynamicForm";
import { EmptyStateText } from "./EmptyStateText"; import { EmptyStateText } from "./EmptyStateText";
@@ -35,7 +38,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
); );
const handleChange = useCallback( const handleChange = useCallback(
async (authentication: Record<string, unknown>) => await patchModel(model, { authentication }), async (authentication: Record<string, unknown>) =>
await patchModel(model, { authentication }),
[model], [model],
); );
@@ -47,7 +51,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
return ( return (
<EmptyStateText> <EmptyStateText>
<p> <p>
Auth plugin not found for <InlineCode>{model.authenticationType}</InlineCode> Auth plugin not found for{" "}
<InlineCode>{model.authenticationType}</InlineCode>
</p> </p>
</EmptyStateText> </EmptyStateText>
); );
@@ -56,11 +61,20 @@ export function HttpAuthenticationEditor({ model }: Props) {
if (inheritedAuth == null) { if (inheritedAuth == null) {
if (model.model === "workspace" || model.model === "folder") { if (model.model === "workspace" || model.model === "folder") {
return ( return (
<EmptyStateText className="flex-col gap-1"> <EmptyStateText className="flex-col gap-3">
<p> <div className="not-italic flex flex-col items-center gap-3 text-center">
Apply auth to all requests in <strong>{resolvedModelName(model)}</strong> <p className="max-w-md text-sm text-text-subtle">
</p> Choose an auth method to apply it to all requests in{" "}
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">Documentation</Link> <strong className="font-semibold text-text-subtle">
{resolvedModelName(model)}
</strong>
.
</p>
<AuthenticationTypeDropdown model={model} />
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">
Documentation
</Link>
</div>
</EmptyStateText> </EmptyStateText>
); );
} }
@@ -83,7 +97,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
type="submit" type="submit"
className="underline hover:text-text" className="underline hover:text-text"
onClick={() => { onClick={() => {
if (inheritedAuth.model === "folder") openFolderSettings(inheritedAuth.id, "auth"); if (inheritedAuth.model === "folder")
openFolderSettings(inheritedAuth.id, "auth");
else openWorkspaceSettings("auth"); else openWorkspaceSettings("auth");
}} }}
> >
@@ -103,7 +118,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
hideLabel hideLabel
name="enabled" name="enabled"
value={ value={
model.authentication.disabled === false || model.authentication.disabled == null model.authentication.disabled === false ||
model.authentication.disabled == null
? "__TRUE__" ? "__TRUE__"
: model.authentication.disabled === true : model.authentication.disabled === true
? "__FALSE__" ? "__FALSE__"
@@ -151,7 +167,9 @@ export function HttpAuthenticationEditor({ model }: Props) {
className="w-full" className="w-full"
stateKey={`auth.${model.id}.dynamic`} stateKey={`auth.${model.id}.dynamic`}
value={model.authentication.disabled} value={model.authentication.disabled}
onChange={(v) => handleChange({ ...model.authentication, disabled: v })} onChange={(v) =>
handleChange({ ...model.authentication, disabled: v })
}
/> />
</div> </div>
)} )}
@@ -169,6 +187,33 @@ export function HttpAuthenticationEditor({ model }: Props) {
); );
} }
function AuthenticationTypeDropdown({ model }: Props) {
const options = useAuthDropdownOptions(model);
if (options == null) return null;
return (
<RadioDropdown
items={options.items}
itemsAfter={options.itemsAfter}
itemsBefore={options.itemsBefore}
value={options.value}
onChange={options.onChange}
>
<Button
color="secondary"
variant="border"
size="sm"
rightSlot={
<Icon icon="chevron_down" size="sm" className="text-text-subtle" />
}
>
Select Auth
</Button>
</RadioDropdown>
);
}
function AuthenticationDisabledInput({ function AuthenticationDisabledInput({
value, value,
onChange, onChange,
@@ -198,7 +243,11 @@ function AuthenticationDisabledInput({
rightSlot={ rightSlot={
<div className="px-1 flex items-center"> <div className="px-1 flex items-center">
<div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap"> <div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap">
{rendered.isPending ? "loading" : rendered.data ? "enabled" : "disabled"} {rendered.isPending
? "loading"
: rendered.data
? "enabled"
: "disabled"}
</div> </div>
</div> </div>
} }
@@ -1,6 +1,7 @@
import { VStack } from "@yaakapp-internal/ui"; import { VStack } from "@yaakapp-internal/ui";
import { useState } from "react"; import { useState } from "react";
import { useLocalStorage } from "react-use"; import { useLocalStorage } from "react-use";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { SelectFile } from "./SelectFile"; import { SelectFile } from "./SelectFile";
@@ -14,6 +15,8 @@ export function ImportDataDialog({ importData }: Props) {
return ( return (
<VStack space={5} className="pb-4"> <VStack space={5} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<VStack space={1}> <VStack space={1}>
<ul className="list-disc pl-5"> <ul className="list-disc pl-5">
<li>OpenAPI 3.0, 3.1</li> <li>OpenAPI 3.0, 3.1</li>
@@ -13,6 +13,7 @@ import {
modelSupportsSetting, modelSupportsSetting,
type RequestSettingDefinition, type RequestSettingDefinition,
SETTING_FOLLOW_REDIRECTS, SETTING_FOLLOW_REDIRECTS,
SETTING_REQUEST_MESSAGE_SIZE,
SETTING_REQUEST_TIMEOUT, SETTING_REQUEST_TIMEOUT,
SETTING_SEND_COOKIES, SETTING_SEND_COOKIES,
SETTING_STORE_COOKIES, SETTING_STORE_COOKIES,
@@ -22,21 +23,44 @@ import { Checkbox } from "./core/Checkbox";
import { PlainInput } from "./core/PlainInput"; import { PlainInput } from "./core/PlainInput";
import { import {
SettingOverrideRow, SettingOverrideRow,
SettingRow,
SettingRowBoolean, SettingRowBoolean,
SettingRowNumber,
SettingsList, SettingsList,
SettingsSection, SettingsSection,
} from "./core/SettingRow"; } from "./core/SettingRow";
const BYTES_PER_MB = 1024 * 1024;
const MAX_REQUEST_MESSAGE_SIZE_BYTES = 2_147_483_647;
const MAX_MESSAGE_SIZE_MB = MAX_REQUEST_MESSAGE_SIZE_BYTES / BYTES_PER_MB;
interface Props { interface Props {
showSectionTitles?: boolean; showSectionTitles?: boolean;
model: ModelWithSettings; model: ModelWithSettings;
} }
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest; type ModelWithSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithHttpSettings = Workspace | Folder | HttpRequest; type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest; type ModelWithTlsSettings =
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest; | Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithCookieSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest;
type ModelWithMessageSizeSettings =
| Workspace
| Folder
| WebsocketRequest
| GrpcRequest;
type BooleanSetting = boolean | InheritedBoolSetting; type BooleanSetting = boolean | InheritedBoolSetting;
type IntegerSetting = number | InheritedIntSetting; type IntegerSetting = number | InheritedIntSetting;
type CookieSettingsPatch = { type CookieSettingsPatch = {
@@ -50,12 +74,19 @@ type HttpSettingsPatch = {
type TlsSettingsPatch = { type TlsSettingsPatch = {
settingValidateCertificates?: ModelWithTlsSettings["settingValidateCertificates"]; settingValidateCertificates?: ModelWithTlsSettings["settingValidateCertificates"];
}; };
type MessageSizeSettingsPatch = {
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
};
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) { export function ModelSettingsEditor({
model,
showSectionTitles = false,
}: Props) {
const ancestors = useModelAncestors(model); const ancestors = useModelAncestors(model);
const supportsHttpSettings = modelSupportsHttpSettings(model); const supportsHttpSettings = modelSupportsHttpSettings(model);
const supportsCookieSettings = modelSupportsCookieSettings(model); const supportsCookieSettings = modelSupportsCookieSettings(model);
const supportsTlsSettings = modelSupportsTlsSettings(model); const supportsTlsSettings = modelSupportsTlsSettings(model);
const supportsMessageSizeSettings = modelSupportsMessageSizeSettings(model);
return ( return (
<SettingsList className="space-y-8"> <SettingsList className="space-y-8">
@@ -77,6 +108,22 @@ export function ModelSettingsEditor({ model, showSectionTitles = false }: Props)
} }
/> />
)} )}
{supportsMessageSizeSettings && (
<MessageSizeSettingRow
settingDefinition={SETTING_REQUEST_MESSAGE_SIZE}
setting={model.settingRequestMessageSize}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_REQUEST_MESSAGE_SIZE.modelKey,
model.settingRequestMessageSize,
)}
onChange={(settingRequestMessageSize) =>
patchMessageSizeSettings(model, {
settingRequestMessageSize,
})
}
/>
)}
<BooleanSettingRow <BooleanSettingRow
settingDefinition={SETTING_VALIDATE_CERTIFICATES} settingDefinition={SETTING_VALIDATE_CERTIFICATES}
setting={model.settingValidateCertificates} setting={model.settingValidateCertificates}
@@ -110,7 +157,9 @@ export function ModelSettingsEditor({ model, showSectionTitles = false }: Props)
</SettingsSection> </SettingsSection>
)} )}
{supportsCookieSettings && ( {supportsCookieSettings && (
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}> <SettingsSection
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
>
<BooleanSettingRow <BooleanSettingRow
settingDefinition={SETTING_SEND_COOKIES} settingDefinition={SETTING_SEND_COOKIES}
setting={model.settingSendCookies} setting={model.settingSendCookies}
@@ -158,46 +207,103 @@ export function countOverriddenSettings(model: ModelWithSettings) {
settings.push(model.settingFollowRedirects, model.settingRequestTimeout); settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
} }
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true) if (modelSupportsMessageSizeSettings(model)) {
.length; settings.push(model.settingRequestMessageSize);
}
return settings.filter(
(setting) => isInheritedSetting(setting) && setting.enabled === true,
).length;
} }
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) { function patchCookieSettings(
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>); model: ModelWithCookieSettings,
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>); patch: Partial<CookieSettingsPatch>,
if (model.model === "http_request") return patchModel(model, patch as Partial<HttpRequest>); ) {
if (model.model === "websocket_request") switch (model.model) {
return patchModel(model, patch as Partial<WebsocketRequest>); case "workspace":
throw new Error("Unsupported cookie settings model"); return patchModel(model, patch as Partial<Workspace>);
case "folder":
return patchModel(model, patch as Partial<Folder>);
case "http_request":
return patchModel(model, patch as Partial<HttpRequest>);
case "websocket_request":
return patchModel(model, patch as Partial<WebsocketRequest>);
}
} }
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) { function patchHttpSettings(
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>); model: ModelWithHttpSettings,
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>); patch: Partial<HttpSettingsPatch>,
return patchModel(model, patch as Partial<HttpRequest>); ) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
case "folder":
return patchModel(model, patch as Partial<Folder>);
case "http_request":
return patchModel(model, patch as Partial<HttpRequest>);
}
} }
function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) { function patchTlsSettings(
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>); model: ModelWithTlsSettings,
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>); patch: Partial<TlsSettingsPatch>,
if (model.model === "http_request") return patchModel(model, patch as Partial<HttpRequest>); ) {
if (model.model === "websocket_request") switch (model.model) {
return patchModel(model, patch as Partial<WebsocketRequest>); case "workspace":
return patchModel(model, patch as Partial<GrpcRequest>); return patchModel(model, patch as Partial<Workspace>);
case "folder":
return patchModel(model, patch as Partial<Folder>);
case "http_request":
return patchModel(model, patch as Partial<HttpRequest>);
case "websocket_request":
return patchModel(model, patch as Partial<WebsocketRequest>);
case "grpc_request":
return patchModel(model, patch as Partial<GrpcRequest>);
}
} }
function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings { function patchMessageSizeSettings(
model: ModelWithMessageSizeSettings,
patch: Partial<MessageSizeSettingsPatch>,
) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
case "folder":
return patchModel(model, patch as Partial<Folder>);
case "websocket_request":
return patchModel(model, patch as Partial<WebsocketRequest>);
case "grpc_request":
return patchModel(model, patch as Partial<GrpcRequest>);
}
}
function modelSupportsHttpSettings(
model: ModelWithSettings,
): model is ModelWithHttpSettings {
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT); return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
} }
function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings { function modelSupportsCookieSettings(
model: ModelWithSettings,
): model is ModelWithCookieSettings {
return modelSupportsSetting(model, SETTING_SEND_COOKIES); return modelSupportsSetting(model, SETTING_SEND_COOKIES);
} }
function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings { function modelSupportsTlsSettings(
model: ModelWithSettings,
): model is ModelWithTlsSettings {
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES); return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
} }
function modelSupportsMessageSizeSettings(
model: ModelWithSettings,
): model is ModelWithMessageSizeSettings {
return modelSupportsSetting(model, SETTING_REQUEST_MESSAGE_SIZE);
}
function BooleanSettingRow({ function BooleanSettingRow({
inheritedValue, inheritedValue,
setting, setting,
@@ -211,7 +317,11 @@ function BooleanSettingRow({
}) { }) {
const inherited = isInheritedSetting(setting); const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false; const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting; const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
if (!inherited) { if (!inherited) {
return ( return (
@@ -255,19 +365,28 @@ function IntegerSettingRow({
}) { }) {
const inherited = isInheritedSetting(setting); const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false; const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting; const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
if (!inherited) { if (!inherited) {
return ( return (
<SettingRowNumber <SettingRow
name={settingDefinition.modelKey}
title={settingDefinition.title} title={settingDefinition.title}
description={settingDefinition.description} description={settingDefinition.description}
value={value} >
placeholder={`${settingDefinition.defaultValue}`} <NumberUnitInput
validate={(value) => value === "" || Number.parseInt(value, 10) >= 0} name={settingDefinition.modelKey}
onChange={(value) => onChange(value)} label={settingDefinition.title}
/> unit="ms"
value={`${value}`}
placeholder={`${settingDefinition.defaultValue}`}
validate={isValidInteger}
onChange={(value) => onChange(parseInteger(value))}
/>
</SettingRow>
); );
} }
@@ -278,21 +397,18 @@ function IntegerSettingRow({
overridden={overridden} overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })} onResetOverride={() => onChange({ ...setting, enabled: false })}
> >
<PlainInput <NumberUnitInput
hideLabel
name={settingDefinition.modelKey} name={settingDefinition.modelKey}
label={settingDefinition.title} label={settingDefinition.title}
size="sm" unit="ms"
type="number" value={`${value}`}
placeholder={`${settingDefinition.defaultValue}`} placeholder={`${settingDefinition.defaultValue}`}
defaultValue={`${value}`} validate={isValidInteger}
containerClassName="!w-48"
validate={(value) => value === "" || Number.parseInt(value, 10) >= 0}
onChange={(value) => onChange={(value) =>
onChange({ onChange({
...setting, ...setting,
enabled: true, enabled: true,
value: Number.parseInt(value, 10) || 0, value: parseInteger(value),
}) })
} }
/> />
@@ -300,6 +416,141 @@ function IntegerSettingRow({
); );
} }
function MessageSizeSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: number;
setting: IntegerSetting;
settingDefinition: RequestSettingDefinition<"settingRequestMessageSize">;
onChange: (setting: IntegerSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
const displayValue = formatMegabytes(value);
const placeholder = "0";
if (!inherited) {
return (
<SettingRow
title={settingDefinition.title}
description={settingDefinition.description}
>
<MessageSizeInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
value={displayValue}
placeholder={placeholder}
onChange={(value) => onChange(parseMegabytes(value))}
/>
</SettingRow>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<MessageSizeInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
value={displayValue}
placeholder={placeholder}
onChange={(value) =>
onChange({
...setting,
enabled: true,
value: parseMegabytes(value),
})
}
/>
</SettingOverrideRow>
);
}
function MessageSizeInput({
label,
name,
onChange,
placeholder,
value,
}: {
label: string;
name: string;
onChange: (value: string) => void;
placeholder: string;
value: string;
}) {
return (
<NumberUnitInput
name={name}
label={label}
unit="MB"
value={value}
inputMode="decimal"
step="any"
placeholder={placeholder}
validate={isValidMegabytes}
onChange={onChange}
/>
);
}
function NumberUnitInput({
inputMode,
label,
name,
onChange,
placeholder,
step,
unit,
validate,
value,
}: {
inputMode?: "decimal" | "numeric";
label: string;
name: string;
onChange: (value: string) => void;
placeholder: string;
step?: number | "any";
unit: string;
validate: (value: string) => boolean;
value: string;
}) {
return (
<PlainInput
hideLabel
name={name}
label={label}
size="sm"
type="number"
inputMode={inputMode}
step={step}
placeholder={placeholder}
defaultValue={value}
className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
containerClassName="!w-48"
validate={validate}
rightSlot={
<span className="flex self-stretch items-center border-l border-border-subtle px-2 text-xs font-medium text-text-subtle">
{unit}
</span>
}
onChange={onChange}
/>
);
}
function isInheritedSetting<T>( function isInheritedSetting<T>(
setting: T | { enabled?: boolean; value: T }, setting: T | { enabled?: boolean; value: T },
): setting is { enabled?: boolean; value: T } { ): setting is { enabled?: boolean; value: T } {
@@ -308,7 +559,7 @@ function isInheritedSetting<T>(
function resolveInheritedValue( function resolveInheritedValue(
ancestors: (Folder | Workspace)[], ancestors: (Folder | Workspace)[],
key: "settingRequestTimeout", key: "settingRequestTimeout" | "settingRequestMessageSize",
fallback: IntegerSetting, fallback: IntegerSetting,
): number; ): number;
function resolveInheritedValue( function resolveInheritedValue(
@@ -338,10 +589,46 @@ function resolveInheritedValue(
type WorkspaceSettings = Pick< type WorkspaceSettings = Pick<
Workspace, Workspace,
| "settingFollowRedirects" | "settingFollowRedirects"
| "settingRequestMessageSize"
| "settingRequestTimeout" | "settingRequestTimeout"
| "settingSendCookies" | "settingSendCookies"
| "settingStoreCookies" | "settingStoreCookies"
| "settingValidateCertificates" | "settingValidateCertificates"
>; >;
type BooleanWorkspaceSettingKey = Exclude<keyof WorkspaceSettings, "settingRequestTimeout">; type BooleanWorkspaceSettingKey = Exclude<
keyof WorkspaceSettings,
"settingRequestTimeout" | "settingRequestMessageSize"
>;
function formatMegabytes(bytes: number) {
const megabytes = bytes / BYTES_PER_MB;
return Number.isInteger(megabytes)
? `${megabytes}`
: megabytes.toFixed(3).replace(/\.?0+$/, "");
}
function parseMegabytes(value: string) {
const megabytes = Number(value);
return Number.isFinite(megabytes) ? Math.round(megabytes * BYTES_PER_MB) : 0;
}
function parseInteger(value: string) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.trunc(parsed) : 0;
}
function isValidInteger(value: string) {
const parsed = Number(value);
return value === "" || (Number.isInteger(parsed) && parsed >= 0);
}
function isValidMegabytes(value: string) {
if (value === "") return true;
const megabytes = Number(value);
return (
Number.isFinite(megabytes) &&
megabytes >= 0 &&
megabytes <= MAX_MESSAGE_SIZE_MB
);
}
@@ -4,6 +4,7 @@ import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useRef } from "react"; import { useRef } from "react";
import { showConfirmDelete } from "../../lib/confirm"; import { showConfirmDelete } from "../../lib/confirm";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox"; import { Checkbox } from "../core/Checkbox";
import { DetailsBanner } from "../core/DetailsBanner"; import { DetailsBanner } from "../core/DetailsBanner";
@@ -232,6 +233,8 @@ export function SettingsCertificates() {
</HStack> </HStack>
</div> </div>
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
{certificates.length > 0 && ( {certificates.length > 0 && (
<VStack space={3}> <VStack space={3}>
{certificates.map((cert, index) => ( {certificates.map((cert, index) => (
@@ -2,22 +2,15 @@ import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { patchModel, settingsAtom } from "@yaakapp-internal/models"; import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, VStack } from "@yaakapp-internal/ui"; import { Heading, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates"; import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
import { appInfo } from "../../lib/appInfo"; import { appInfo } from "../../lib/appInfo";
import {
SETTING_FOLLOW_REDIRECTS,
SETTING_REQUEST_TIMEOUT,
SETTING_SEND_COOKIES,
SETTING_STORE_COOKIES,
SETTING_VALIDATE_CERTIFICATES,
} from "../../lib/requestSettings";
import { revealInFinderText } from "../../lib/reveal"; import { revealInFinderText } from "../../lib/reveal";
import { CargoFeature } from "../CargoFeature"; import { CargoFeature } from "../CargoFeature";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { DismissibleBanner } from "../core/DismissibleBanner";
import { IconButton } from "../core/IconButton"; import { IconButton } from "../core/IconButton";
import { import {
ModelSettingRowBoolean, ModelSettingRowBoolean,
ModelSettingRowNumber,
ModelSettingSelectControl, ModelSettingSelectControl,
SettingValue, SettingValue,
SettingRow, SettingRow,
@@ -27,20 +20,29 @@ import {
SettingsSection, SettingsSection,
} from "../core/SettingRow"; } from "../core/SettingRow";
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
export function SettingsGeneral() { export function SettingsGeneral() {
const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const checkForUpdates = useCheckForUpdates(); const checkForUpdates = useCheckForUpdates();
if (settings == null || workspace == null) { if (settings == null) {
return null; return null;
} }
const showWorkspaceSettingsMovedBanner =
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
return ( return (
<VStack space={1.5} className="mb-4"> <VStack space={1.5} className="mb-4">
<div className="mb-4"> <div>
<Heading>General</Heading> <Heading>General</Heading>
<p className="text-text-subtle">Configure general settings for update behavior and more.</p> <p className="text-text-subtle">
Configure general settings for update behavior and more.
</p>
</div>
<div className="mt-3 mb-5">
<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />
</div> </div>
<SettingsList className="space-y-8"> <SettingsList className="space-y-8">
<CargoFeature feature="updater"> <CargoFeature feature="updater">
@@ -76,7 +78,9 @@ export function SettingsGeneral() {
description="Choose whether updates are installed automatically or manually." description="Choose whether updates are installed automatically or manually."
name="autoupdate" name="autoupdate"
value={settings.autoupdate ? "auto" : "manual"} value={settings.autoupdate ? "auto" : "manual"}
onChange={(v) => patchModel(settings, { autoupdate: v === "auto" })} onChange={(v) =>
patchModel(settings, { autoupdate: v === "auto" })
}
options={[ options={[
{ label: "Automatic", value: "auto" }, { label: "Automatic", value: "auto" },
{ label: "Manual", value: "manual" }, { label: "Manual", value: "manual" },
@@ -108,54 +112,19 @@ export function SettingsGeneral() {
</SettingsSection> </SettingsSection>
</CargoFeature> </CargoFeature>
<SettingsSection {showWorkspaceSettingsMovedBanner && (
title={ <DismissibleBanner
<> id="workspace-settings-moved-2026-06-30"
Workspace{" "} color="info"
<span className="inline-block bg-surface-highlight px-2 py-0.5 rounded text"> className="p-4 max-w-xl mx-auto"
{workspace.name} >
</span> <p>
</> Workspace specific settings have moved to{" "}
} <b>Workspace Settings</b>, accessible from the workspace switcher
> menu.
<ModelSettingRowNumber </p>
model={workspace} </DismissibleBanner>
modelKey={SETTING_REQUEST_TIMEOUT.modelKey} )}
title={SETTING_REQUEST_TIMEOUT.title}
description={SETTING_REQUEST_TIMEOUT.description}
placeholder={`${SETTING_REQUEST_TIMEOUT.defaultValue}`}
required
validate={(value) => Number.parseInt(value, 10) >= 0}
/>
<ModelSettingRowBoolean
model={workspace}
modelKey={SETTING_VALIDATE_CERTIFICATES.modelKey}
title={SETTING_VALIDATE_CERTIFICATES.title}
description={SETTING_VALIDATE_CERTIFICATES.description}
/>
<ModelSettingRowBoolean
model={workspace}
modelKey={SETTING_FOLLOW_REDIRECTS.modelKey}
title={SETTING_FOLLOW_REDIRECTS.title}
description={SETTING_FOLLOW_REDIRECTS.description}
/>
<ModelSettingRowBoolean
model={workspace}
modelKey={SETTING_SEND_COOKIES.modelKey}
title={SETTING_SEND_COOKIES.title}
description={SETTING_SEND_COOKIES.description}
/>
<ModelSettingRowBoolean
model={workspace}
modelKey={SETTING_STORE_COOKIES.modelKey}
title={SETTING_STORE_COOKIES.title}
description={SETTING_STORE_COOKIES.description}
/>
</SettingsSection>
<SettingsSection title="App Info"> <SettingsSection title="App Info">
<SettingRow title="Version" description="Current Yaak version."> <SettingRow title="Version" description="Current Yaak version.">
@@ -8,6 +8,7 @@ import { useAtomValue } from "jotai";
import { useState } from "react"; import { useState } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace"; import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { showConfirm } from "../../lib/confirm"; import { showConfirm } from "../../lib/confirm";
import { pricingUrl } from "../../lib/pricingUrl";
import { invokeCmd } from "../../lib/tauri"; import { invokeCmd } from "../../lib/tauri";
import { CargoFeature } from "../CargoFeature"; import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
@@ -252,7 +253,9 @@ function LicenseSettings({ settings }: { settings: Settings }) {
</p> </p>
<p> <p>
Licenses help keep Yaak independent and sustainable.{" "} Licenses help keep Yaak independent and sustainable.{" "}
<Link href="https://yaak.app/pricing?s=badge">Purchase a License </Link> <Link href={pricingUrl("app.license.badge-hide-confirm")}>
Purchase a License
</Link>
</p> </p>
</VStack> </VStack>
), ),
@@ -6,6 +6,7 @@ import { formatDate } from "date-fns/format";
import { useState } from "react"; import { useState } from "react";
import { useToggle } from "../../hooks/useToggle"; import { useToggle } from "../../hooks/useToggle";
import { pluralizeCount } from "../../lib/pluralize"; import { pluralizeCount } from "../../lib/pluralize";
import { pricingUrl } from "../../lib/pricingUrl";
import { CargoFeature } from "../CargoFeature"; import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import { Link } from "../core/Link"; import { Link } from "../core/Link";
@@ -48,7 +49,7 @@ function SettingsLicenseCmp() {
<span className="opacity-50">Personal use is always free, forever.</span> <span className="opacity-50">Personal use is always free, forever.</span>
<Separator className="my-2" /> <Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice"> <div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}> <Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
Learn More Learn More
</Link> </Link>
</div> </div>
@@ -68,7 +69,7 @@ function SettingsLicenseCmp() {
</span> </span>
<Separator className="my-2" /> <Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice"> <div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}> <Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
Learn More Learn More
</Link> </Link>
</div> </div>
@@ -134,7 +135,7 @@ function SettingsLicenseCmp() {
<Button <Button
color="secondary" color="secondary"
size="sm" size="sm"
onClick={() => openUrl("https://yaak.app/dashboard?s=support&ref=app.yaak.desktop")} onClick={() => openUrl("https://yaak.app/dashboard?intent=app.license.support")}
rightSlot={<Icon icon="external_link" />} rightSlot={<Icon icon="external_link" />}
> >
Direct Support Direct Support
@@ -150,9 +151,7 @@ function SettingsLicenseCmp() {
color="primary" color="primary"
rightSlot={<Icon icon="external_link" />} rightSlot={<Icon icon="external_link" />}
onClick={() => onClick={() =>
openUrl( openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
`https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop&t=${check.data?.status ?? ""}`,
)
} }
> >
Purchase License Purchase License
@@ -2,6 +2,7 @@ import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import type { ProxySetting } from "@yaakapp-internal/models"; import type { ProxySetting } from "@yaakapp-internal/models";
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui"; import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { import {
SettingRowBoolean, SettingRowBoolean,
SettingRowSelect, SettingRowSelect,
@@ -33,6 +34,7 @@ export function SettingsProxy() {
traffic, or routing through specific infrastructure. traffic, or routing through specific infrastructure.
</p> </p>
</div> </div>
<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />
<SettingsList className="space-y-8"> <SettingsList className="space-y-8">
<SettingsSection title="Proxy"> <SettingsSection title="Proxy">
<SettingRowSelect <SettingRowSelect
@@ -7,6 +7,7 @@ import { useExportData } from "../hooks/useExportData";
import { appInfo } from "../lib/appInfo"; import { appInfo } from "../lib/appInfo";
import { showDialog } from "../lib/dialog"; import { showDialog } from "../lib/dialog";
import { importData } from "../lib/importData"; import { importData } from "../lib/importData";
import { pricingUrl } from "../lib/pricingUrl";
import type { DropdownRef } from "./core/Dropdown"; import type { DropdownRef } from "./core/Dropdown";
import { Dropdown } from "./core/Dropdown"; import { Dropdown } from "./core/Dropdown";
import { Icon } from "@yaakapp-internal/ui"; import { Icon } from "@yaakapp-internal/ui";
@@ -76,7 +77,8 @@ export function SettingsDropdown() {
hidden: check.data == null || check.data.status === "active", hidden: check.data == null || check.data.status === "active",
leftSlot: <Icon icon="circle_dollar_sign" />, leftSlot: <Icon icon="circle_dollar_sign" />,
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />, rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
onSelect: () => openUrl("https://yaak.app/pricing"), onSelect: () =>
openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
}, },
{ {
label: "Install CLI", label: "Install CLI",
@@ -105,10 +105,18 @@ function WebsocketEventRow({
: ""; : "";
const iconColor = const iconColor =
messageType === "close" || messageType === "open" ? "secondary" : isServer ? "info" : "primary"; messageType === "error"
? "warning"
: messageType === "close" || messageType === "open"
? "secondary"
: isServer
? "info"
: "primary";
const icon = const icon =
messageType === "close" || messageType === "open" messageType === "error"
? "alert_triangle"
: messageType === "close" || messageType === "open"
? "info" ? "info"
: isServer : isServer
? "arrow_big_down_dash" ? "arrow_big_down_dash"
@@ -119,6 +127,8 @@ function WebsocketEventRow({
"Disconnected from server" "Disconnected from server"
) : messageType === "open" ? ( ) : messageType === "open" ? (
"Connected to server" "Connected to server"
) : messageType === "error" ? (
<span className="text-warning">{message}</span>
) : message === "" ? ( ) : message === "" ? (
<em className="italic text-text-subtlest">No content</em> <em className="italic text-text-subtlest">No content</em>
) : ( ) : (
@@ -170,7 +180,9 @@ function WebsocketEventDetail({
? "Connection Closed" ? "Connection Closed"
: event.messageType === "open" : event.messageType === "open"
? "Connection Open" ? "Connection Open"
: `Message ${event.isServer ? "Received" : "Sent"}`; : event.messageType === "error"
? "WebSocket Error"
: `Message ${event.isServer ? "Received" : "Sent"}`;
const actions: EventDetailAction[] = const actions: EventDetailAction[] =
message !== "" message !== ""
@@ -112,7 +112,9 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
onCreateNewWorkspace={hide} onCreateNewWorkspace={hide}
onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })} onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })}
/> />
<WorkspaceEncryptionSetting layout="settings" size="xs" /> <div className="mt-4">
<WorkspaceEncryptionSetting layout="settings" size="xs" />
</div>
</SettingsSection> </SettingsSection>
<ModelSettingsEditor model={workspace} showSectionTitles /> <ModelSettingsEditor model={workspace} showSectionTitles />
</SettingsList> </SettingsList>
@@ -1,57 +1,84 @@
import type { Color } from "@yaakapp-internal/plugins"; import type { Color } from "@yaakapp-internal/plugins";
import type { BannerProps } from "@yaakapp-internal/ui"; import type { BannerProps } from "@yaakapp-internal/ui";
import { Banner, HStack } from "@yaakapp-internal/ui"; import { Banner } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useEffect } from "react";
import { useKeyValue } from "../../hooks/useKeyValue"; import { useKeyValue } from "../../hooks/useKeyValue";
import type { ButtonProps } from "./Button";
import { Button } from "./Button"; import { Button } from "./Button";
export function DismissibleBanner({ export function DismissibleBanner({
children, children,
className, className,
id, id,
onDismiss,
onShow,
actions, actions,
...props ...props
}: BannerProps & { }: BannerProps & {
id: string; id: string;
actions?: { label: string; onClick: () => void; color?: Color }[]; onDismiss?: () => void | Promise<void>;
onShow?: () => void | Promise<void>;
actions?: {
label: string;
onClick: () => void;
color?: Color;
variant?: ButtonProps["variant"];
}[];
}) { }) {
const { set: setDismissed, value: dismissed } = useKeyValue<boolean>({ const {
isLoading,
set: setDismissed,
value: dismissed,
} = useKeyValue<boolean>({
namespace: "global", namespace: "global",
key: ["dismiss-banner", id], key: ["dismiss-banner", id],
fallback: false, fallback: false,
}); });
if (dismissed) return null; const shouldShow = !isLoading && !dismissed;
useEffect(() => {
if (shouldShow) {
Promise.resolve(onShow?.()).catch(console.error);
}
}, [onShow, shouldShow]);
if (!shouldShow) return null;
return ( return (
<Banner <Banner className={classNames(className, "relative")} {...props}>
className={classNames(className, "relative grid grid-cols-[1fr_auto] gap-3")} <div className="@container">
{...props} <div className="grid gap-2 @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center @[34rem]:gap-3">
> {children}
{children} <div className="flex flex-wrap gap-1.5 @[34rem]:justify-end">
<HStack space={1.5}> <Button
{actions?.map((a) => ( variant="border"
<Button color={props.color}
key={a.label} size="xs"
variant="border" onClick={() => {
color={a.color ?? props.color} setDismissed(true).catch(console.error);
size="xs" Promise.resolve(onDismiss?.()).catch(console.error);
onClick={a.onClick} }}
title={a.label} title="Dismiss message"
> >
{a.label} Dismiss
</Button> </Button>
))} {actions?.map((a) => (
<Button <Button
variant="border" key={a.label}
color={props.color} variant={a.variant ?? "border"}
size="xs" color={a.color ?? props.color}
onClick={() => setDismissed((d) => !d)} size="xs"
title="Dismiss message" onClick={a.onClick}
> title={a.label}
Dismiss >
</Button> {a.label}
</HStack> </Button>
))}
</div>
</div>
</div>
</Banner> </Banner>
); );
} }
@@ -1,6 +1,6 @@
import { HStack } from "@yaakapp-internal/ui"; import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import type { FocusEvent, HTMLAttributes, ReactNode } from "react"; import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react";
import { import {
forwardRef, forwardRef,
useCallback, useCallback,
@@ -28,10 +28,9 @@ export type PlainInputProps = Omit<
| "extraExtensions" | "extraExtensions"
| "forcedEnvironmentId" | "forcedEnvironmentId"
> & > &
Pick<HTMLAttributes<HTMLInputElement>, "onKeyDownCapture"> & { Pick<InputHTMLAttributes<HTMLInputElement>, "inputMode" | "onKeyDownCapture" | "step"> & {
onFocusRaw?: HTMLAttributes<HTMLInputElement>["onFocus"]; onFocusRaw?: InputHTMLAttributes<HTMLInputElement>["onFocus"];
type?: "text" | "password" | "number"; type?: "text" | "password" | "number";
step?: number;
hideObscureToggle?: boolean; hideObscureToggle?: boolean;
labelRightSlot?: ReactNode; labelRightSlot?: ReactNode;
}; };
@@ -52,6 +51,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
labelClassName, labelClassName,
labelPosition = "top", labelPosition = "top",
labelRightSlot, labelRightSlot,
inputMode,
leftSlot, leftSlot,
name, name,
onBlur, onBlur,
@@ -64,6 +64,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
required, required,
rightSlot, rightSlot,
size = "md", size = "md",
step,
tint, tint,
type = "text", type = "text",
validate, validate,
@@ -204,12 +205,14 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
autoComplete="off" autoComplete="off"
autoCapitalize="off" autoCapitalize="off"
autoCorrect="off" autoCorrect="off"
inputMode={inputMode}
onChange={(e) => handleChange(e.target.value)} onChange={(e) => handleChange(e.target.value)}
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))} onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
className={classNames(commonClassName, "h-full disabled:opacity-disabled")} className={classNames(commonClassName, "h-full disabled:opacity-disabled")}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
required={required} required={required}
step={step}
placeholder={placeholder} placeholder={placeholder}
onKeyDownCapture={onKeyDownCapture} onKeyDownCapture={onKeyDownCapture}
/> />
@@ -16,6 +16,7 @@ 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";
import { sync } from "../../init/sync"; import { sync } from "../../init/sync";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import type { CheckboxProps } from "../core/Checkbox"; import type { CheckboxProps } from "../core/Checkbox";
import { Checkbox } from "../core/Checkbox"; import { Checkbox } from "../core/Checkbox";
@@ -205,7 +206,8 @@ 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"> <div style={style} className="h-full px-4 grid grid-rows-[auto_minmax(0,1fr)] gap-3">
<CommercialUseBanner source="git-commit" title="Using Git for work?" />
<SplitLayout <SplitLayout
storageKey="commit-vertical" storageKey="commit-vertical"
layout="vertical" layout="vertical"
@@ -9,7 +9,7 @@ export async function addGitRemote(dir: string, defaultName?: string): Promise<G
title: "Add Remote", title: "Add Remote",
inputs: [ inputs: [
{ type: "text", label: "Name", name: "name", defaultValue: defaultName }, { type: "text", label: "Name", name: "name", defaultValue: defaultName },
{ type: "text", label: "URL", name: "url" }, { type: "text", label: "URL", name: "url", placeholder: "git@github.com:org/repo.git" },
], ],
}); });
if (r == null) throw new Error("Cancelled remote prompt"); if (r == null) throw new Error("Cancelled remote prompt");
+160 -123
View File
@@ -5,6 +5,7 @@ import { useMemo } from "react";
import { openFolderSettings } from "../commands/openFolderSettings"; import { openFolderSettings } from "../commands/openFolderSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings"; import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { IconTooltip } from "../components/core/IconTooltip"; import { IconTooltip } from "../components/core/IconTooltip";
import type { RadioDropdownProps } from "../components/core/RadioDropdown";
import type { TabItem } from "../components/core/Tabs/Tabs"; import type { TabItem } from "../components/core/Tabs/Tabs";
import { capitalize } from "../lib/capitalize"; import { capitalize } from "../lib/capitalize";
import { showConfirm } from "../lib/confirm"; import { showConfirm } from "../lib/confirm";
@@ -14,156 +15,192 @@ import type { AuthenticatedModel } from "./useInheritedAuthentication";
import { useInheritedAuthentication } from "./useInheritedAuthentication"; import { useInheritedAuthentication } from "./useInheritedAuthentication";
import { useModelAncestors } from "./useModelAncestors"; import { useModelAncestors } from "./useModelAncestors";
export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedModel | null) { export function useAuthTab<T extends string>(
tabValue: T,
model: AuthenticatedModel | null,
) {
const options = useAuthDropdownOptions(model);
return useMemo<TabItem[]>(() => {
if (model == null || options == null) return [];
const tab: TabItem = {
value: tabValue,
label: "Auth",
options,
};
return [tab];
}, [model, options, tabValue]);
}
export function useAuthDropdownOptions(
model: AuthenticatedModel | null,
): Omit<RadioDropdownProps, "children"> | null {
const authentication = useHttpAuthenticationSummaries(); const authentication = useHttpAuthenticationSummaries();
const inheritedAuth = useInheritedAuthentication(model); const inheritedAuth = useInheritedAuthentication(model);
const ancestors = useModelAncestors(model); const ancestors = useModelAncestors(model);
const parentModel = ancestors[0] ?? null; const parentModel = ancestors[0] ?? null;
return useMemo<TabItem[]>(() => { return useMemo(() => {
if (model == null) return []; if (model == null) return null;
const tab: TabItem = { return {
value: tabValue, value: model.authenticationType,
label: "Auth", items: [
options: { ...authentication.map((a) => ({
value: model.authenticationType, label: a.label || "UNKNOWN",
items: [ shortLabel: a.shortLabel,
...authentication.map((a) => ({ value: a.name,
label: a.label || "UNKNOWN", })),
shortLabel: a.shortLabel, { type: "separator" },
value: a.name, {
})), label: "Inherit from Parent",
{ type: "separator" }, shortLabel:
{ inheritedAuth != null &&
label: "Inherit from Parent", inheritedAuth.authenticationType !== "none" ? (
shortLabel: <HStack space={1.5}>
inheritedAuth != null && inheritedAuth.authenticationType !== "none" ? ( {authentication.find(
<HStack space={1.5}> (a) => a.name === inheritedAuth.authenticationType,
{authentication.find((a) => a.name === inheritedAuth.authenticationType) )?.shortLabel ?? "UNKNOWN"}
?.shortLabel ?? "UNKNOWN"} <IconTooltip
<IconTooltip icon="zap_off"
icon="zap_off" iconSize="xs"
iconSize="xs" content="Authentication was inherited from an ancestor"
content="Authentication was inherited from an ancestor" />
/> </HStack>
</HStack> ) : (
) : ( "Auth"
"Auth" ),
), value: null,
value: null, },
}, { label: "No Auth", shortLabel: "No Auth", value: "none" },
{ label: "No Auth", shortLabel: "No Auth", value: "none" }, ],
], itemsAfter: (() => {
itemsAfter: (() => { const actions: (
const actions: ( | { type: "separator"; label: string }
| { type: "separator"; label: string } | {
| { label: string; leftSlot: React.ReactNode; onSelect: () => Promise<void> } label: string;
)[] = []; leftSlot: React.ReactNode;
onSelect: () => Promise<void>;
// Promote: move auth from current model up to parent
if (
parentModel &&
model.authenticationType &&
model.authenticationType !== "none" &&
(parentModel.authenticationType == null || parentModel.authenticationType === "none")
) {
actions.push(
{ type: "separator", label: "Actions" },
{
label: `Promote to ${capitalize(parentModel.model)}`,
leftSlot: (
<Icon
icon={parentModel.model === "workspace" ? "corner_right_up" : "folder_up"}
/>
),
onSelect: async () => {
const confirmed = await showConfirm({
id: "promote-auth-confirm",
title: "Promote Authentication",
confirmText: "Promote",
description: (
<>
Move authentication config to{" "}
<InlineCode>{resolvedModelName(parentModel)}</InlineCode>?
</>
),
});
if (confirmed) {
await patchModel(model, { authentication: {}, authenticationType: null });
await patchModel(parentModel, {
authentication: model.authentication,
authenticationType: model.authenticationType,
});
if (parentModel.model === "folder") {
openFolderSettings(parentModel.id, "auth");
} else {
openWorkspaceSettings("auth");
}
}
},
},
);
}
// Copy from ancestor: copy auth config down to current model
const ancestorWithAuth = ancestors.find(
(a) => a.authenticationType != null && a.authenticationType !== "none",
);
if (ancestorWithAuth) {
if (actions.length === 0) {
actions.push({ type: "separator", label: "Actions" });
} }
actions.push({ )[] = [];
label: `Copy from ${modelTypeLabel(ancestorWithAuth)}`,
// Promote: move auth from current model up to parent
if (
parentModel &&
model.authenticationType &&
model.authenticationType !== "none" &&
(parentModel.authenticationType == null ||
parentModel.authenticationType === "none")
) {
actions.push(
{ type: "separator", label: "Actions" },
{
label: `Promote to ${capitalize(parentModel.model)}`,
leftSlot: ( leftSlot: (
<Icon <Icon
icon={ icon={
ancestorWithAuth.model === "workspace" ? "corner_right_down" : "folder_down" parentModel.model === "workspace"
? "corner_right_up"
: "folder_up"
} }
/> />
), ),
onSelect: async () => { onSelect: async () => {
const confirmed = await showConfirm({ const confirmed = await showConfirm({
id: "copy-auth-confirm", id: "promote-auth-confirm",
title: "Copy Authentication", title: "Promote Authentication",
confirmText: "Copy", confirmText: "Promote",
description: ( description: (
<> <>
Copy{" "} Move authentication config to{" "}
{authentication.find((a) => a.name === ancestorWithAuth.authenticationType) <InlineCode>{resolvedModelName(parentModel)}</InlineCode>?
?.label ?? "authentication"}{" "}
config from <InlineCode>{resolvedModelName(ancestorWithAuth)}</InlineCode>?
This will override the current authentication but will not affect the{" "}
{modelTypeLabel(ancestorWithAuth).toLowerCase()}.
</> </>
), ),
}); });
if (confirmed) { if (confirmed) {
await patchModel(model, { await patchModel(model, {
authentication: { ...ancestorWithAuth.authentication }, authentication: {},
authenticationType: ancestorWithAuth.authenticationType, authenticationType: null,
}); });
await patchModel(parentModel, {
authentication: model.authentication,
authenticationType: model.authenticationType,
});
if (parentModel.model === "folder") {
openFolderSettings(parentModel.id, "auth");
} else {
openWorkspaceSettings("auth");
}
} }
}, },
}); },
} );
}
return actions.length > 0 ? actions : undefined; // Copy from ancestor: copy auth config down to current model
})(), const ancestorWithAuth = ancestors.find(
onChange: async (authenticationType) => { (a) =>
let authentication: Folder["authentication"] = model.authentication; a.authenticationType != null && a.authenticationType !== "none",
if (model.authenticationType !== authenticationType) { );
authentication = { if (ancestorWithAuth) {
// Reset auth if changing types if (actions.length === 0) {
}; actions.push({ type: "separator", label: "Actions" });
} }
await patchModel(model, { authentication, authenticationType }); actions.push({
}, label: `Copy from ${modelTypeLabel(ancestorWithAuth)}`,
leftSlot: (
<Icon
icon={
ancestorWithAuth.model === "workspace"
? "corner_right_down"
: "folder_down"
}
/>
),
onSelect: async () => {
const confirmed = await showConfirm({
id: "copy-auth-confirm",
title: "Copy Authentication",
confirmText: "Copy",
description: (
<>
Copy{" "}
{authentication.find(
(a) => a.name === ancestorWithAuth.authenticationType,
)?.label ?? "authentication"}{" "}
config from{" "}
<InlineCode>
{resolvedModelName(ancestorWithAuth)}
</InlineCode>
? This will override the current authentication but will not
affect the {modelTypeLabel(ancestorWithAuth).toLowerCase()}.
</>
),
});
if (confirmed) {
await patchModel(model, {
authentication: { ...ancestorWithAuth.authentication },
authenticationType: ancestorWithAuth.authenticationType,
});
}
},
});
}
return actions.length > 0 ? actions : undefined;
})(),
onChange: async (authenticationType) => {
let authentication: Folder["authentication"] = model.authentication;
if (model.authenticationType !== authenticationType) {
authentication = {
// Reset auth if changing types
};
}
await patchModel(model, { authentication, authenticationType });
}, },
}; };
}, [authentication, inheritedAuth, model, parentModel, ancestors]);
return [tab];
}, [authentication, inheritedAuth, model, parentModel, tabValue, ancestors]);
} }
+3
View File
@@ -0,0 +1,3 @@
export function pricingUrl(intent: string): string {
return `https://yaak.app/pricing?intent=${encodeURIComponent(intent)}`;
}
+24 -4
View File
@@ -5,6 +5,7 @@ type ModelType = AnyModel["model"];
type WorkspaceRequestSettings = Pick< type WorkspaceRequestSettings = Pick<
Workspace, Workspace,
| "settingFollowRedirects" | "settingFollowRedirects"
| "settingRequestMessageSize"
| "settingRequestTimeout" | "settingRequestTimeout"
| "settingSendCookies" | "settingSendCookies"
| "settingStoreCookies" | "settingStoreCookies"
@@ -17,7 +18,9 @@ type ModelTypeWithSetting<K extends RequestSettingKey> = {
[M in ModelType]: K extends keyof ModelForType<M> ? M : never; [M in ModelType]: K extends keyof ModelForType<M> ? M : never;
}[ModelType]; }[ModelType];
export type RequestSettingDefinition<K extends RequestSettingKey = RequestSettingKey> = { export type RequestSettingDefinition<
K extends RequestSettingKey = RequestSettingKey,
> = {
defaultValue: WorkspaceRequestSettings[K]; defaultValue: WorkspaceRequestSettings[K];
description: string; description: string;
modelKey: K; modelKey: K;
@@ -41,11 +44,26 @@ export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
title: "Request Timeout", title: "Request Timeout",
}); });
export const SETTING_REQUEST_MESSAGE_SIZE = defineRequestSetting({
defaultValue: 64 * 1024 * 1024,
description:
"Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.",
modelKey: "settingRequestMessageSize",
models: ["workspace", "folder", "websocket_request", "grpc_request"],
title: "Message Size Limit",
});
export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({ export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({
defaultValue: true, defaultValue: true,
description: "When disabled, skip validation of server certificates.", description: "When disabled, skip validation of server certificates.",
modelKey: "settingValidateCertificates", modelKey: "settingValidateCertificates",
models: ["workspace", "folder", "http_request", "websocket_request", "grpc_request"], models: [
"workspace",
"folder",
"http_request",
"websocket_request",
"grpc_request",
],
title: "Validate TLS certificates", title: "Validate TLS certificates",
}); });
@@ -59,7 +77,8 @@ export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
export const SETTING_SEND_COOKIES = defineRequestSetting({ export const SETTING_SEND_COOKIES = defineRequestSetting({
defaultValue: true, defaultValue: true,
description: "Attach matching cookies from the active cookie jar to outgoing requests.", description:
"Attach matching cookies from the active cookie jar to outgoing requests.",
modelKey: "settingSendCookies", modelKey: "settingSendCookies",
models: ["workspace", "folder", "http_request", "websocket_request"], models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically send cookies", title: "Automatically send cookies",
@@ -67,7 +86,8 @@ export const SETTING_SEND_COOKIES = defineRequestSetting({
export const SETTING_STORE_COOKIES = defineRequestSetting({ export const SETTING_STORE_COOKIES = defineRequestSetting({
defaultValue: true, defaultValue: true,
description: "Save cookies from Set-Cookie response headers to the active cookie jar.", description:
"Save cookies from Set-Cookie response headers to the active cookie jar.",
modelKey: "settingStoreCookies", modelKey: "settingStoreCookies",
models: ["workspace", "folder", "http_request", "websocket_request"], models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically store cookies", title: "Automatically store cookies",
+3 -3
View File
@@ -66,7 +66,7 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-pdf": "^10.0.1", "react-pdf": "^10.0.1",
"react-syntax-highlighter": "^16.1.0", "react-syntax-highlighter": "^16.1.0",
"react-use": "^17.6.0", "react-use": "^17.6.1",
"rehype-stringify": "^10.0.1", "rehype-stringify": "^10.0.1",
"remark-frontmatter": "^5.0.0", "remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
@@ -102,11 +102,11 @@
"postcss-nesting": "^13.0.2", "postcss-nesting": "^13.0.2",
"rollup": "^4.60.3", "rollup": "^4.60.3",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"vite-plugin-static-copy": "^3.3.0", "vite-plugin-static-copy": "^3.3.0",
"vite-plugin-svgr": "^4.5.0", "vite-plugin-svgr": "^4.5.0",
"vite-plugin-top-level-await": "^1.5.0", "vite-plugin-top-level-await": "^1.5.0",
"vite-plugin-wasm": "^3.5.0", "vite-plugin-wasm": "^3.5.0",
"vite-plus": "^0.1.20" "vite-plus": "^0.2.1"
} }
} }
+1
View File
@@ -39,6 +39,7 @@ export default defineConfig(async () => {
}), }),
], ],
build: { build: {
target: "esnext",
sourcemap: true, sourcemap: true,
outDir: "../../dist/apps/yaak-client", outDir: "../../dist/apps/yaak-client",
emptyOutDir: true, emptyOutDir: true,
+2 -2
View File
@@ -31,7 +31,7 @@
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"vite-plus": "^0.1.20" "vite-plus": "^0.2.1"
} }
} }
+5 -1
View File
@@ -2,7 +2,7 @@ use crate::models_ext::QueryManagerExt;
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
use log::debug; use log::debug;
use std::sync::OnceLock; use std::sync::OnceLock;
use tauri::{AppHandle, Runtime}; use tauri::{AppHandle, Runtime, is_dev};
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
const NAMESPACE: &str = "analytics"; const NAMESPACE: &str = "analytics";
@@ -36,6 +36,10 @@ pub fn get_or_upsert_launch_info<R: Runtime>(app_handle: &AppHandle<R>) -> &Laun
..Default::default() ..Default::default()
}; };
if is_dev() {
info.current_version = "0.0.1".to_string();
}
app_handle app_handle
.with_tx(|tx| { .with_tx(|tx| {
// Load the previously tracked version // Load the previously tracked version
+15 -9
View File
@@ -295,7 +295,8 @@ async fn cmd_grpc_reflect<R: Runtime>(
unrendered_request.folder_id.as_deref(), unrendered_request.folder_id.as_deref(),
environment_id, environment_id,
)?; )?;
let resolved_settings = app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?; let resolved_settings =
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
@@ -332,6 +333,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
&metadata, &metadata,
resolved_settings.validate_certificates.value, resolved_settings.validate_certificates.value,
client_certificate, client_certificate,
resolved_settings.request_message_size.value,
) )
.await .await
.map_err(|e| GenericError(e.to_string()))?) .map_err(|e| GenericError(e.to_string()))?)
@@ -353,7 +355,8 @@ async fn cmd_grpc_go<R: Runtime>(
unrendered_request.folder_id.as_deref(), unrendered_request.folder_id.as_deref(),
environment_id, environment_id,
)?; )?;
let resolved_settings = app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?; let resolved_settings =
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
@@ -425,6 +428,7 @@ async fn cmd_grpc_go<R: Runtime>(
&metadata, &metadata,
resolved_settings.validate_certificates.value, resolved_settings.validate_certificates.value,
client_cert.clone(), client_cert.clone(),
resolved_settings.request_message_size.value,
) )
.await; .await;
@@ -714,7 +718,7 @@ async fn cmd_grpc_go<R: Runtime>(
Some(s) => GrpcEvent { Some(s) => GrpcEvent {
error: Some(s.message().to_string()), error: Some(s.message().to_string()),
status: Some(s.code() as i32), status: Some(s.code() as i32),
content: "Failed to connect".to_string(), content: "Request failed".to_string(),
metadata: metadata_to_map(s.metadata().clone()), metadata: metadata_to_map(s.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
@@ -722,7 +726,7 @@ async fn cmd_grpc_go<R: Runtime>(
None => GrpcEvent { None => GrpcEvent {
error: Some(e.message), error: Some(e.message),
status: Some(Code::Unknown as i32), status: Some(Code::Unknown as i32),
content: "Failed to connect".to_string(), content: "Request failed".to_string(),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
}, },
@@ -738,7 +742,7 @@ async fn cmd_grpc_go<R: Runtime>(
&GrpcEvent { &GrpcEvent {
error: Some(e.to_string()), error: Some(e.to_string()),
status: Some(Code::Unknown as i32), status: Some(Code::Unknown as i32),
content: "Failed to connect".to_string(), content: "Request failed".to_string(),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
}, },
@@ -781,7 +785,7 @@ async fn cmd_grpc_go<R: Runtime>(
Some(s) => GrpcEvent { Some(s) => GrpcEvent {
error: Some(s.message().to_string()), error: Some(s.message().to_string()),
status: Some(s.code() as i32), status: Some(s.code() as i32),
content: "Failed to connect".to_string(), content: "Stream failed".to_string(),
metadata: metadata_to_map(s.metadata().clone()), metadata: metadata_to_map(s.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
@@ -789,7 +793,7 @@ async fn cmd_grpc_go<R: Runtime>(
None => GrpcEvent { None => GrpcEvent {
error: Some(e.message), error: Some(e.message),
status: Some(Code::Unknown as i32), status: Some(Code::Unknown as i32),
content: "Failed to connect".to_string(), content: "Stream failed".to_string(),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
}, },
@@ -806,7 +810,7 @@ async fn cmd_grpc_go<R: Runtime>(
&GrpcEvent { &GrpcEvent {
error: Some(e.to_string()), error: Some(e.to_string()),
status: Some(Code::Unknown as i32), status: Some(Code::Unknown as i32),
content: "Failed to connect".to_string(), content: "Stream failed".to_string(),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
}, },
@@ -878,7 +882,8 @@ async fn cmd_grpc_go<R: Runtime>(
.db() .db()
.upsert_grpc_event( .upsert_grpc_event(
&GrpcEvent { &GrpcEvent {
content: status.to_string(), content: "Stream failed".to_string(),
error: Some(status.message().to_string()),
status: Some(status.code() as i32), status: Some(status.code() as i32),
metadata: metadata_to_map(status.metadata().clone()), metadata: metadata_to_map(status.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
@@ -887,6 +892,7 @@ async fn cmd_grpc_go<R: Runtime>(
&UpdateSource::from_window_label(window.label()), &UpdateSource::from_window_label(window.label()),
) )
.unwrap(); .unwrap();
break;
} }
} }
} }
@@ -79,7 +79,7 @@ impl YaakNotifier {
return Ok(()); return Ok(());
} }
debug!("Checking for notifications"); info!("Checking for notifications");
#[cfg(feature = "license")] #[cfg(feature = "license")]
let license_check = { let license_check = {
@@ -115,17 +115,20 @@ impl YaakNotifier {
]); ]);
let resp = req.send().await?; let resp = req.send().await?;
if resp.status() != 200 { if resp.status() != 200 {
debug!("Skipping notification status code {}", resp.status()); info!("Skipping notification status code {}", resp.status());
return Ok(()); return Ok(());
} }
for notification in resp.json::<Vec<YaakNotification>>().await? { let notifications = resp.json::<Vec<YaakNotification>>().await?;
debug!("Received {} notifications", notifications.len());
for notification in notifications {
let seen = get_kv(app_handle).await?; let seen = get_kv(app_handle).await?;
if seen.contains(&notification.id) { if seen.contains(&notification.id) {
debug!("Already seen notification {}", notification.id); debug!("Already seen notification {}", notification.id);
continue; continue;
} }
debug!("Got notification {:?}", notification); info!("Got notification {:?}", notification);
let _ = app_handle.emit_to(window.label(), "notification", notification.clone()); let _ = app_handle.emit_to(window.label(), "notification", notification.clone());
break; // Only show one notification break; // Only show one notification
+33 -1
View File
@@ -50,6 +50,37 @@ pub async fn cmd_ws_send<R: Runtime>(
ws_manager: State<'_, Mutex<WebsocketManager>>, ws_manager: State<'_, Mutex<WebsocketManager>>,
) -> Result<WebsocketConnection> { ) -> Result<WebsocketConnection> {
let connection = app_handle.db().get_websocket_connection(connection_id)?; let connection = app_handle.db().get_websocket_connection(connection_id)?;
match send_websocket_message(&connection, environment_id, &app_handle, &window, &ws_manager)
.await
{
Ok(connection) => Ok(connection),
Err(e) => {
app_handle.db().upsert_websocket_event(
&WebsocketEvent {
connection_id: connection.id.clone(),
request_id: connection.request_id.clone(),
workspace_id: connection.workspace_id.clone(),
is_server: false,
message_type: WebsocketEventType::Error,
message: e.to_string().into(),
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
)?;
Ok(connection)
}
}
}
async fn send_websocket_message<R: Runtime>(
connection: &WebsocketConnection,
environment_id: Option<&str>,
app_handle: &AppHandle<R>,
window: &WebviewWindow<R>,
ws_manager: &Mutex<WebsocketManager>,
) -> Result<WebsocketConnection> {
let unrendered_request = app_handle.db().get_websocket_request(&connection.request_id)?; let unrendered_request = app_handle.db().get_websocket_request(&connection.request_id)?;
let environment_chain = app_handle.db().resolve_environments( let environment_chain = app_handle.db().resolve_environments(
&unrendered_request.workspace_id, &unrendered_request.workspace_id,
@@ -91,7 +122,7 @@ pub async fn cmd_ws_send<R: Runtime>(
&UpdateSource::from_window_label(window.label()), &UpdateSource::from_window_label(window.label()),
)?; )?;
Ok(connection) Ok(connection.clone())
} }
#[command] #[command]
@@ -299,6 +330,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
receive_tx, receive_tx,
resolved_settings.validate_certificates.value, resolved_settings.validate_certificates.value,
client_cert, client_cert,
resolved_settings.request_message_size.value,
) )
.await .await
{ {
+4
View File
@@ -46,6 +46,7 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting; settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting; settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type GrpcRequest = { export type GrpcRequest = {
@@ -69,6 +70,7 @@ export type GrpcRequest = {
*/ */
url: string; url: string;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type HttpRequest = { export type HttpRequest = {
@@ -146,6 +148,7 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting; settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting; settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type Workspace = { export type Workspace = {
@@ -162,6 +165,7 @@ export type Workspace = {
settingValidateCertificates: boolean; settingValidateCertificates: boolean;
settingFollowRedirects: boolean; settingFollowRedirects: boolean;
settingRequestTimeout: number; settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>; settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean; settingSendCookies: boolean;
settingStoreCookies: boolean; settingStoreCookies: boolean;
+11 -5
View File
@@ -33,15 +33,21 @@ impl AutoReflectionClient {
uri: &Uri, uri: &Uri,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<Self> { ) -> Result<Self> {
let client_v1 = v1::server_reflection_client::ServerReflectionClient::with_origin( let client_v1 = v1::server_reflection_client::ServerReflectionClient::with_origin(
get_transport(validate_certificates, client_cert.clone())?, get_transport(validate_certificates, client_cert.clone())?,
uri.clone(), uri.clone(),
); )
let client_v1alpha = v1alpha::server_reflection_client::ServerReflectionClient::with_origin( .max_decoding_message_size(max_message_size)
get_transport(validate_certificates, client_cert.clone())?, .max_encoding_message_size(max_message_size);
uri.clone(), let client_v1alpha =
); v1alpha::server_reflection_client::ServerReflectionClient::with_origin(
get_transport(validate_certificates, client_cert.clone())?,
uri.clone(),
)
.max_decoding_message_size(max_message_size)
.max_encoding_message_size(max_message_size);
Ok(AutoReflectionClient { use_v1alpha: false, client_v1, client_v1alpha }) Ok(AutoReflectionClient { use_v1alpha: false, client_v1, client_v1alpha })
} }
+82 -14
View File
@@ -39,6 +39,7 @@ pub struct GrpcConnection {
conn: Client<HttpsConnector<HttpConnector>, BoxBody>, conn: Client<HttpsConnector<HttpConnector>, BoxBody>,
pub uri: Uri, pub uri: Uri,
use_reflection: bool, use_reflection: bool,
max_message_size: usize,
} }
#[derive(Default, Debug)] #[derive(Default, Debug)]
@@ -97,8 +98,15 @@ impl GrpcConnection {
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
) -> Result<Response<DynamicMessage>> { ) -> Result<Response<DynamicMessage>> {
if self.use_reflection { if self.use_reflection {
reflect_types_for_message(self.pool.clone(), &self.uri, message, metadata, client_cert) reflect_types_for_message(
.await?; self.pool.clone(),
&self.uri,
message,
metadata,
client_cert,
self.max_message_size,
)
.await?;
} }
let method = &self.method(&service, &method).await?; let method = &self.method(&service, &method).await?;
let input_message = method.input(); let input_message = method.input();
@@ -107,7 +115,7 @@ impl GrpcConnection {
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?; let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
deserializer.end()?; deserializer.end()?;
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone()); let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
let mut req = req_message.into_request(); let mut req = req_message.into_request();
decorate_req(metadata, &mut req)?; decorate_req(metadata, &mut req)?;
@@ -132,6 +140,7 @@ impl GrpcConnection {
message, message,
metadata, metadata,
client_cert, client_cert,
self.max_message_size,
) )
.await?; .await?;
@@ -171,6 +180,7 @@ impl GrpcConnection {
let md = metadata.clone(); let md = metadata.clone();
let use_reflection = self.use_reflection.clone(); let use_reflection = self.use_reflection.clone();
let client_cert = client_cert.clone(); let client_cert = client_cert.clone();
let max_message_size = self.max_message_size;
stream stream
.then(move |json| { .then(move |json| {
let pool = pool.clone(); let pool = pool.clone();
@@ -183,8 +193,15 @@ impl GrpcConnection {
let json_clone = json.clone(); let json_clone = json.clone();
async move { async move {
if use_reflection { if use_reflection {
if let Err(e) = if let Err(e) = reflect_types_for_message(
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await pool,
&uri,
&json,
&md,
client_cert,
max_message_size,
)
.await
{ {
warn!("Failed to resolve Any types: {e}"); warn!("Failed to resolve Any types: {e}");
} }
@@ -206,7 +223,7 @@ impl GrpcConnection {
.filter_map(|x| x) .filter_map(|x| x)
}; };
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone()); let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
let path = method_desc_to_path(method); let path = method_desc_to_path(method);
let codec = DynamicCodec::new(method.clone()); let codec = DynamicCodec::new(method.clone());
@@ -237,6 +254,7 @@ impl GrpcConnection {
let md = metadata.clone(); let md = metadata.clone();
let use_reflection = self.use_reflection.clone(); let use_reflection = self.use_reflection.clone();
let client_cert = client_cert.clone(); let client_cert = client_cert.clone();
let max_message_size = self.max_message_size;
stream stream
.then(move |json| { .then(move |json| {
let pool = pool.clone(); let pool = pool.clone();
@@ -249,8 +267,15 @@ impl GrpcConnection {
let json_clone = json.clone(); let json_clone = json.clone();
async move { async move {
if use_reflection { if use_reflection {
if let Err(e) = if let Err(e) = reflect_types_for_message(
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await pool,
&uri,
&json,
&md,
client_cert,
max_message_size,
)
.await
{ {
warn!("Failed to resolve Any types: {e}"); warn!("Failed to resolve Any types: {e}");
} }
@@ -272,7 +297,7 @@ impl GrpcConnection {
.filter_map(|x| x) .filter_map(|x| x)
}; };
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone()); let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
let path = method_desc_to_path(method); let path = method_desc_to_path(method);
let codec = DynamicCodec::new(method.clone()); let codec = DynamicCodec::new(method.clone());
@@ -300,7 +325,7 @@ impl GrpcConnection {
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?; let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
deserializer.end()?; deserializer.end()?;
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone()); let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
let mut req = req_message.into_request(); let mut req = req_message.into_request();
decorate_req(metadata, &mut req)?; decorate_req(metadata, &mut req)?;
@@ -312,6 +337,23 @@ impl GrpcConnection {
} }
} }
fn grpc_client(
conn: Client<HttpsConnector<HttpConnector>, BoxBody>,
uri: Uri,
max_message_size: usize,
) -> tonic::client::Grpc<Client<HttpsConnector<HttpConnector>, BoxBody>> {
tonic::client::Grpc::with_origin(conn, uri)
.max_decoding_message_size(max_message_size)
.max_encoding_message_size(max_message_size)
}
fn message_size_limit(setting: i32) -> usize {
match setting.try_into() {
Ok(0) | Err(_) => usize::MAX,
Ok(limit) => limit,
}
}
/// Configuration for GrpcHandle to compile proto files /// Configuration for GrpcHandle to compile proto files
#[derive(Clone)] #[derive(Clone)]
pub struct GrpcConfig { pub struct GrpcConfig {
@@ -348,6 +390,7 @@ impl GrpcHandle {
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<bool> { ) -> Result<bool> {
let server_reflection = proto_files.is_empty(); let server_reflection = proto_files.is_empty();
let key = make_pool_key(id, uri, proto_files); let key = make_pool_key(id, uri, proto_files);
@@ -359,7 +402,14 @@ impl GrpcHandle {
let pool = if server_reflection { let pool = if server_reflection {
let full_uri = uri_from_str(uri)?; let full_uri = uri_from_str(uri)?;
fill_pool_from_reflection(&full_uri, metadata, validate_certificates, client_cert).await fill_pool_from_reflection(
&full_uri,
metadata,
validate_certificates,
client_cert,
message_size_limit(request_message_size),
)
.await
} else { } else {
fill_pool_from_files(&self.config, proto_files).await fill_pool_from_files(&self.config, proto_files).await
}?; }?;
@@ -376,12 +426,21 @@ impl GrpcHandle {
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<Vec<ServiceDefinition>> { ) -> Result<Vec<ServiceDefinition>> {
// Ensure we have a pool; reflect only if missing // Ensure we have a pool; reflect only if missing
if self.get_pool(id, uri, proto_files).is_none() { if self.get_pool(id, uri, proto_files).is_none() {
info!("Reflecting gRPC services for {} at {}", id, uri); info!("Reflecting gRPC services for {} at {}", id, uri);
self.reflect(id, uri, proto_files, metadata, validate_certificates, client_cert) self.reflect(
.await?; id,
uri,
proto_files,
metadata,
validate_certificates,
client_cert,
request_message_size,
)
.await?;
} }
let pool = self let pool = self
@@ -421,8 +480,10 @@ impl GrpcHandle {
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<GrpcConnection> { ) -> Result<GrpcConnection> {
let use_reflection = proto_files.is_empty(); let use_reflection = proto_files.is_empty();
let max_message_size = message_size_limit(request_message_size);
if self.get_pool(id, uri, proto_files).is_none() { if self.get_pool(id, uri, proto_files).is_none() {
self.reflect( self.reflect(
id, id,
@@ -431,6 +492,7 @@ impl GrpcHandle {
metadata, metadata,
validate_certificates, validate_certificates,
client_cert.clone(), client_cert.clone(),
request_message_size,
) )
.await?; .await?;
} }
@@ -440,7 +502,13 @@ impl GrpcHandle {
.clone(); .clone();
let uri = uri_from_str(uri)?; let uri = uri_from_str(uri)?;
let conn = get_transport(validate_certificates, client_cert.clone())?; let conn = get_transport(validate_certificates, client_cert.clone())?;
Ok(GrpcConnection { pool: Arc::new(RwLock::new(pool)), use_reflection, conn, uri }) Ok(GrpcConnection {
pool: Arc::new(RwLock::new(pool)),
use_reflection,
conn,
uri,
max_message_size,
})
} }
fn get_pool(&self, id: &str, uri: &str, proto_files: &Vec<PathBuf>) -> Option<&DescriptorPool> { fn get_pool(&self, id: &str, uri: &str, proto_files: &Vec<PathBuf>) -> Option<&DescriptorPool> {
+7 -3
View File
@@ -119,9 +119,11 @@ pub async fn fill_pool_from_reflection(
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<DescriptorPool> { ) -> Result<DescriptorPool> {
let mut pool = DescriptorPool::new(); let mut pool = DescriptorPool::new();
let mut client = AutoReflectionClient::new(uri, validate_certificates, client_cert)?; let mut client =
AutoReflectionClient::new(uri, validate_certificates, client_cert, max_message_size)?;
for service in list_services(&mut client, metadata).await? { for service in list_services(&mut client, metadata).await? {
if service == "grpc.reflection.v1alpha.ServerReflection" { if service == "grpc.reflection.v1alpha.ServerReflection" {
@@ -192,6 +194,7 @@ pub(crate) async fn reflect_types_for_message(
json: &str, json: &str,
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<()> { ) -> Result<()> {
// 1. Collect all Any types in the JSON // 1. Collect all Any types in the JSON
let mut extra_types = Vec::new(); let mut extra_types = Vec::new();
@@ -201,7 +204,7 @@ pub(crate) async fn reflect_types_for_message(
return Ok(()); // nothing to do return Ok(()); // nothing to do
} }
let mut client = AutoReflectionClient::new(uri, false, client_cert)?; let mut client = AutoReflectionClient::new(uri, false, client_cert, max_message_size)?;
for extra_type in extra_types { for extra_type in extra_types {
{ {
let guard = pool.read().await; let guard = pool.read().await;
@@ -239,6 +242,7 @@ pub(crate) async fn reflect_types_for_dynamic_message(
message: &DynamicMessage, message: &DynamicMessage,
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<()> { ) -> Result<()> {
let mut extra_types = HashSet::new(); let mut extra_types = HashSet::new();
collect_any_types_from_dynamic_message(message, &mut extra_types); collect_any_types_from_dynamic_message(message, &mut extra_types);
@@ -247,7 +251,7 @@ pub(crate) async fn reflect_types_for_dynamic_message(
return Ok(()); return Ok(());
} }
let mut client = AutoReflectionClient::new(uri, false, client_cert)?; let mut client = AutoReflectionClient::new(uri, false, client_cert, max_message_size)?;
for extra_type in extra_types { for extra_type in extra_types {
{ {
let guard = pool.read().await; let guard = pool.read().await;
+6 -1
View File
@@ -109,6 +109,7 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting; settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting; settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type GraphQlIntrospection = { export type GraphQlIntrospection = {
@@ -184,6 +185,7 @@ export type GrpcRequest = {
*/ */
url: string; url: string;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type HttpRequest = { export type HttpRequest = {
@@ -456,7 +458,8 @@ export type WebsocketEvent = {
messageType: WebsocketEventType; messageType: WebsocketEventType;
}; };
export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text"; export type WebsocketEventType =
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketMessageType = "text" | "binary"; export type WebsocketMessageType = "text" | "binary";
@@ -482,6 +485,7 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting; settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting; settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type Workspace = { export type Workspace = {
@@ -498,6 +502,7 @@ export type Workspace = {
settingValidateCertificates: boolean; settingValidateCertificates: boolean;
settingFollowRedirects: boolean; settingFollowRedirects: boolean;
settingRequestTimeout: number; settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>; settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean; settingSendCookies: boolean;
settingStoreCookies: boolean; settingStoreCookies: boolean;
+1 -1
View File
@@ -54,7 +54,7 @@ function trackModelWrite<T>(write: Promise<T>): Promise<T> {
} }
export async function flushAllModelWrites(): Promise<void> { export async function flushAllModelWrites(): Promise<void> {
const results = await Promise.allSettled([...pendingModelWrites]); const results = await Promise.allSettled(pendingModelWrites);
const rejected = results.find((result) => result.status === "rejected"); const rejected = results.find((result) => result.status === "rejected");
if (rejected?.status === "rejected") { if (rejected?.status === "rejected") {
throw rejected.reason; throw rejected.reason;
@@ -0,0 +1,7 @@
ALTER TABLE workspaces ADD COLUMN setting_request_message_size INTEGER DEFAULT 67108864 NOT NULL;
ALTER TABLE folders ADD COLUMN setting_request_message_size TEXT DEFAULT '{"enabled":false,"value":67108864}' NOT NULL;
ALTER TABLE websocket_requests ADD COLUMN setting_request_message_size TEXT DEFAULT '{"enabled":false,"value":67108864}' NOT NULL;
ALTER TABLE grpc_requests ADD COLUMN setting_request_message_size TEXT DEFAULT '{"enabled":false,"value":67108864}' NOT NULL;
+48 -1
View File
@@ -21,6 +21,8 @@ use ts_rs::TS;
use yaak_database::{Result as DbResult, UpdateSource}; use yaak_database::{Result as DbResult, UpdateSource};
pub use yaak_database::{UpsertModelInfo, upsert_date}; pub use yaak_database::{UpsertModelInfo, upsert_date};
pub const DEFAULT_REQUEST_MESSAGE_SIZE: i32 = 64 * 1024 * 1024;
#[macro_export] #[macro_export]
macro_rules! impl_model { macro_rules! impl_model {
($t:ty, $variant:ident) => { ($t:ty, $variant:ident) => {
@@ -120,6 +122,7 @@ pub struct ResolvedHttpRequestSettings {
pub validate_certificates: ResolvedSetting<bool>, pub validate_certificates: ResolvedSetting<bool>,
pub follow_redirects: ResolvedSetting<bool>, pub follow_redirects: ResolvedSetting<bool>,
pub request_timeout: ResolvedSetting<i32>, pub request_timeout: ResolvedSetting<i32>,
pub request_message_size: ResolvedSetting<i32>,
pub send_cookies: ResolvedSetting<bool>, pub send_cookies: ResolvedSetting<bool>,
pub store_cookies: ResolvedSetting<bool>, pub store_cookies: ResolvedSetting<bool>,
} }
@@ -130,6 +133,7 @@ impl Default for ResolvedHttpRequestSettings {
validate_certificates: ResolvedSetting::default_source(true), validate_certificates: ResolvedSetting::default_source(true),
follow_redirects: ResolvedSetting::default_source(true), follow_redirects: ResolvedSetting::default_source(true),
request_timeout: ResolvedSetting::default_source(0), request_timeout: ResolvedSetting::default_source(0),
request_message_size: ResolvedSetting::default_source(DEFAULT_REQUEST_MESSAGE_SIZE),
send_cookies: ResolvedSetting::default_source(true), send_cookies: ResolvedSetting::default_source(true),
store_cookies: ResolvedSetting::default_source(true), store_cookies: ResolvedSetting::default_source(true),
} }
@@ -400,6 +404,8 @@ pub struct Workspace {
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub setting_follow_redirects: bool, pub setting_follow_redirects: bool,
pub setting_request_timeout: i32, pub setting_request_timeout: i32,
#[serde(default = "default_request_message_size")]
pub setting_request_message_size: i32,
#[serde(default)] #[serde(default)]
pub setting_dns_overrides: Vec<DnsOverride>, pub setting_dns_overrides: Vec<DnsOverride>,
#[serde(default = "default_true")] #[serde(default = "default_true")]
@@ -445,6 +451,7 @@ impl UpsertModelInfo for Workspace {
(EncryptionKeyChallenge, self.encryption_key_challenge.into()), (EncryptionKeyChallenge, self.encryption_key_challenge.into()),
(SettingFollowRedirects, self.setting_follow_redirects.into()), (SettingFollowRedirects, self.setting_follow_redirects.into()),
(SettingRequestTimeout, self.setting_request_timeout.into()), (SettingRequestTimeout, self.setting_request_timeout.into()),
(SettingRequestMessageSize, self.setting_request_message_size.into()),
(SettingValidateCertificates, self.setting_validate_certificates.into()), (SettingValidateCertificates, self.setting_validate_certificates.into()),
(SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()), (SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()),
(SettingSendCookies, self.setting_send_cookies.into()), (SettingSendCookies, self.setting_send_cookies.into()),
@@ -463,7 +470,7 @@ impl UpsertModelInfo for Workspace {
WorkspaceIden::EncryptionKeyChallenge, WorkspaceIden::EncryptionKeyChallenge,
WorkspaceIden::SettingRequestTimeout, WorkspaceIden::SettingRequestTimeout,
WorkspaceIden::SettingFollowRedirects, WorkspaceIden::SettingFollowRedirects,
WorkspaceIden::SettingRequestTimeout, WorkspaceIden::SettingRequestMessageSize,
WorkspaceIden::SettingValidateCertificates, WorkspaceIden::SettingValidateCertificates,
WorkspaceIden::SettingDnsOverrides, WorkspaceIden::SettingDnsOverrides,
WorkspaceIden::SettingSendCookies, WorkspaceIden::SettingSendCookies,
@@ -491,6 +498,7 @@ impl UpsertModelInfo for Workspace {
authentication_type: row.get("authentication_type")?, authentication_type: row.get("authentication_type")?,
setting_follow_redirects: row.get("setting_follow_redirects")?, setting_follow_redirects: row.get("setting_follow_redirects")?,
setting_request_timeout: row.get("setting_request_timeout")?, setting_request_timeout: row.get("setting_request_timeout")?,
setting_request_message_size: row.get("setting_request_message_size")?,
setting_validate_certificates: row.get("setting_validate_certificates")?, setting_validate_certificates: row.get("setting_validate_certificates")?,
setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(), setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(),
setting_send_cookies: row.get("setting_send_cookies")?, setting_send_cookies: row.get("setting_send_cookies")?,
@@ -962,6 +970,8 @@ pub struct Folder {
pub setting_validate_certificates: InheritedBoolSetting, pub setting_validate_certificates: InheritedBoolSetting,
pub setting_follow_redirects: InheritedBoolSetting, pub setting_follow_redirects: InheritedBoolSetting,
pub setting_request_timeout: InheritedIntSetting, pub setting_request_timeout: InheritedIntSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
} }
impl UpsertModelInfo for Folder { impl UpsertModelInfo for Folder {
@@ -1009,6 +1019,10 @@ impl UpsertModelInfo for Folder {
), ),
(SettingFollowRedirects, serde_json::to_string(&self.setting_follow_redirects)?.into()), (SettingFollowRedirects, serde_json::to_string(&self.setting_follow_redirects)?.into()),
(SettingRequestTimeout, serde_json::to_string(&self.setting_request_timeout)?.into()), (SettingRequestTimeout, serde_json::to_string(&self.setting_request_timeout)?.into()),
(
SettingRequestMessageSize,
serde_json::to_string(&self.setting_request_message_size)?.into(),
),
]) ])
} }
@@ -1027,6 +1041,7 @@ impl UpsertModelInfo for Folder {
FolderIden::SettingValidateCertificates, FolderIden::SettingValidateCertificates,
FolderIden::SettingFollowRedirects, FolderIden::SettingFollowRedirects,
FolderIden::SettingRequestTimeout, FolderIden::SettingRequestTimeout,
FolderIden::SettingRequestMessageSize,
] ]
} }
@@ -1041,6 +1056,7 @@ impl UpsertModelInfo for Folder {
let setting_validate_certificates: String = row.get("setting_validate_certificates")?; let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
let setting_follow_redirects: String = row.get("setting_follow_redirects")?; let setting_follow_redirects: String = row.get("setting_follow_redirects")?;
let setting_request_timeout: String = row.get("setting_request_timeout")?; let setting_request_timeout: String = row.get("setting_request_timeout")?;
let setting_request_message_size: String = row.get("setting_request_message_size")?;
Ok(Self { Ok(Self {
id: row.get("id")?, id: row.get("id")?,
model: row.get("model")?, model: row.get("model")?,
@@ -1062,6 +1078,8 @@ impl UpsertModelInfo for Folder {
.unwrap_or_default(), .unwrap_or_default(),
setting_request_timeout: serde_json::from_str(&setting_request_timeout) setting_request_timeout: serde_json::from_str(&setting_request_timeout)
.unwrap_or_default(), .unwrap_or_default(),
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
.unwrap_or_else(|_| default_request_message_size_setting()),
}) })
} }
} }
@@ -1398,6 +1416,8 @@ pub struct WebsocketRequest {
pub setting_send_cookies: InheritedBoolSetting, pub setting_send_cookies: InheritedBoolSetting,
pub setting_store_cookies: InheritedBoolSetting, pub setting_store_cookies: InheritedBoolSetting,
pub setting_validate_certificates: InheritedBoolSetting, pub setting_validate_certificates: InheritedBoolSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
} }
impl UpsertModelInfo for WebsocketRequest { impl UpsertModelInfo for WebsocketRequest {
@@ -1446,6 +1466,10 @@ impl UpsertModelInfo for WebsocketRequest {
SettingValidateCertificates, SettingValidateCertificates,
serde_json::to_string(&self.setting_validate_certificates)?.into(), serde_json::to_string(&self.setting_validate_certificates)?.into(),
), ),
(
SettingRequestMessageSize,
serde_json::to_string(&self.setting_request_message_size)?.into(),
),
]) ])
} }
@@ -1466,6 +1490,7 @@ impl UpsertModelInfo for WebsocketRequest {
WebsocketRequestIden::SettingSendCookies, WebsocketRequestIden::SettingSendCookies,
WebsocketRequestIden::SettingStoreCookies, WebsocketRequestIden::SettingStoreCookies,
WebsocketRequestIden::SettingValidateCertificates, WebsocketRequestIden::SettingValidateCertificates,
WebsocketRequestIden::SettingRequestMessageSize,
] ]
} }
@@ -1479,6 +1504,7 @@ impl UpsertModelInfo for WebsocketRequest {
let setting_send_cookies: String = row.get("setting_send_cookies")?; let setting_send_cookies: String = row.get("setting_send_cookies")?;
let setting_store_cookies: String = row.get("setting_store_cookies")?; let setting_store_cookies: String = row.get("setting_store_cookies")?;
let setting_validate_certificates: String = row.get("setting_validate_certificates")?; let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
let setting_request_message_size: String = row.get("setting_request_message_size")?;
Ok(Self { Ok(Self {
id: row.get("id")?, id: row.get("id")?,
model: row.get("model")?, model: row.get("model")?,
@@ -1499,6 +1525,8 @@ impl UpsertModelInfo for WebsocketRequest {
setting_store_cookies: serde_json::from_str(&setting_store_cookies).unwrap_or_default(), setting_store_cookies: serde_json::from_str(&setting_store_cookies).unwrap_or_default(),
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates) setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
.unwrap_or_default(), .unwrap_or_default(),
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
.unwrap_or_else(|_| default_request_message_size_setting()),
}) })
} }
} }
@@ -1509,6 +1537,7 @@ impl UpsertModelInfo for WebsocketRequest {
pub enum WebsocketEventType { pub enum WebsocketEventType {
Binary, Binary,
Close, Close,
Error,
Frame, Frame,
Open, Open,
Ping, Ping,
@@ -2039,6 +2068,8 @@ pub struct GrpcRequest {
/// Server URL (http for plaintext or https for secure) /// Server URL (http for plaintext or https for secure)
pub url: String, pub url: String,
pub setting_validate_certificates: InheritedBoolSetting, pub setting_validate_certificates: InheritedBoolSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
} }
impl UpsertModelInfo for GrpcRequest { impl UpsertModelInfo for GrpcRequest {
@@ -2086,6 +2117,10 @@ impl UpsertModelInfo for GrpcRequest {
SettingValidateCertificates, SettingValidateCertificates,
serde_json::to_string(&self.setting_validate_certificates)?.into(), serde_json::to_string(&self.setting_validate_certificates)?.into(),
), ),
(
SettingRequestMessageSize,
serde_json::to_string(&self.setting_request_message_size)?.into(),
),
]) ])
} }
@@ -2105,6 +2140,7 @@ impl UpsertModelInfo for GrpcRequest {
GrpcRequestIden::Authentication, GrpcRequestIden::Authentication,
GrpcRequestIden::Metadata, GrpcRequestIden::Metadata,
GrpcRequestIden::SettingValidateCertificates, GrpcRequestIden::SettingValidateCertificates,
GrpcRequestIden::SettingRequestMessageSize,
] ]
} }
@@ -2115,6 +2151,7 @@ impl UpsertModelInfo for GrpcRequest {
let authentication: String = row.get("authentication")?; let authentication: String = row.get("authentication")?;
let metadata: String = row.get("metadata")?; let metadata: String = row.get("metadata")?;
let setting_validate_certificates: String = row.get("setting_validate_certificates")?; let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
let setting_request_message_size: String = row.get("setting_request_message_size")?;
Ok(Self { Ok(Self {
id: row.get("id")?, id: row.get("id")?,
model: row.get("model")?, model: row.get("model")?,
@@ -2134,6 +2171,8 @@ impl UpsertModelInfo for GrpcRequest {
metadata: serde_json::from_str(metadata.as_str()).unwrap_or_default(), metadata: serde_json::from_str(metadata.as_str()).unwrap_or_default(),
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates) setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
.unwrap_or_default(), .unwrap_or_default(),
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
.unwrap_or_else(|_| default_request_message_size_setting()),
}) })
} }
} }
@@ -2684,6 +2723,14 @@ fn default_true() -> bool {
true true
} }
fn default_request_message_size() -> i32 {
DEFAULT_REQUEST_MESSAGE_SIZE
}
fn default_request_message_size_setting() -> InheritedIntSetting {
InheritedIntSetting { enabled: false, value: DEFAULT_REQUEST_MESSAGE_SIZE }
}
fn default_http_method() -> String { fn default_http_method() -> String {
"GET".to_string() "GET".to_string()
} }
@@ -180,6 +180,14 @@ impl<'a> ClientDb<'a> {
} else { } else {
parent.request_timeout parent.request_timeout
}, },
request_message_size: if folder.setting_request_message_size.enabled {
ResolvedSetting::from_model(
folder.setting_request_message_size.value,
AnyModel::Folder(folder.clone()),
)
} else {
parent.request_message_size
},
send_cookies: if folder.setting_send_cookies.enabled { send_cookies: if folder.setting_send_cookies.enabled {
ResolvedSetting::from_model( ResolvedSetting::from_model(
folder.setting_send_cookies.value, folder.setting_send_cookies.value,
@@ -129,6 +129,14 @@ impl<'a> ClientDb<'a> {
} else { } else {
parent.validate_certificates parent.validate_certificates
}, },
request_message_size: if grpc_request.setting_request_message_size.enabled {
ResolvedSetting::from_model(
grpc_request.setting_request_message_size.value,
AnyModel::GrpcRequest(grpc_request.clone()),
)
} else {
parent.request_message_size
},
..parent ..parent
}) })
} }
@@ -131,6 +131,7 @@ impl<'a> ClientDb<'a> {
} else { } else {
parent.request_timeout parent.request_timeout
}, },
request_message_size: parent.request_message_size,
send_cookies: if http_request.setting_send_cookies.enabled { send_cookies: if http_request.setting_send_cookies.enabled {
ResolvedSetting::from_model( ResolvedSetting::from_model(
http_request.setting_send_cookies.value, http_request.setting_send_cookies.value,
@@ -139,6 +139,14 @@ impl<'a> ClientDb<'a> {
} else { } else {
parent.validate_certificates parent.validate_certificates
}, },
request_message_size: if websocket_request.setting_request_message_size.enabled {
ResolvedSetting::from_model(
websocket_request.setting_request_message_size.value,
AnyModel::WebsocketRequest(websocket_request.clone()),
)
} else {
parent.request_message_size
},
send_cookies: if websocket_request.setting_send_cookies.enabled { send_cookies: if websocket_request.setting_send_cookies.enabled {
ResolvedSetting::from_model( ResolvedSetting::from_model(
websocket_request.setting_send_cookies.value, websocket_request.setting_send_cookies.value,
@@ -21,6 +21,7 @@ impl<'a> ClientDb<'a> {
&Workspace { &Workspace {
name: "Yaak".to_string(), name: "Yaak".to_string(),
setting_follow_redirects: true, setting_follow_redirects: true,
setting_request_message_size: crate::models::DEFAULT_REQUEST_MESSAGE_SIZE,
setting_validate_certificates: true, setting_validate_certificates: true,
..Default::default() ..Default::default()
}, },
@@ -102,6 +103,10 @@ impl<'a> ClientDb<'a> {
workspace.setting_request_timeout, workspace.setting_request_timeout,
AnyModel::Workspace(workspace.clone()), AnyModel::Workspace(workspace.clone()),
), ),
request_message_size: ResolvedSetting::from_model(
workspace.setting_request_message_size,
AnyModel::Workspace(workspace.clone()),
),
send_cookies: ResolvedSetting::from_model( send_cookies: ResolvedSetting::from_model(
workspace.setting_send_cookies, workspace.setting_send_cookies,
AnyModel::Workspace(workspace.clone()), AnyModel::Workspace(workspace.clone()),
+6 -1
View File
@@ -108,6 +108,7 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting; settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting; settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type GraphQlIntrospection = { export type GraphQlIntrospection = {
@@ -183,6 +184,7 @@ export type GrpcRequest = {
*/ */
url: string; url: string;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type HttpRequest = { export type HttpRequest = {
@@ -426,7 +428,8 @@ export type WebsocketEvent = {
messageType: WebsocketEventType; messageType: WebsocketEventType;
}; };
export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text"; export type WebsocketEventType =
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketRequest = { export type WebsocketRequest = {
model: "websocket_request"; model: "websocket_request";
@@ -450,6 +453,7 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting; settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting; settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type Workspace = { export type Workspace = {
@@ -466,6 +470,7 @@ export type Workspace = {
settingValidateCertificates: boolean; settingValidateCertificates: boolean;
settingFollowRedirects: boolean; settingFollowRedirects: boolean;
settingRequestTimeout: number; settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>; settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean; settingSendCookies: boolean;
settingStoreCookies: boolean; settingStoreCookies: boolean;
+4
View File
@@ -46,6 +46,7 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting; settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting; settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type GrpcRequest = { export type GrpcRequest = {
@@ -69,6 +70,7 @@ export type GrpcRequest = {
*/ */
url: string; url: string;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type HttpRequest = { export type HttpRequest = {
@@ -159,6 +161,7 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting; settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting; settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type Workspace = { export type Workspace = {
@@ -175,6 +178,7 @@ export type Workspace = {
settingValidateCertificates: boolean; settingValidateCertificates: boolean;
settingFollowRedirects: boolean; settingFollowRedirects: boolean;
settingRequestTimeout: number; settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>; settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean; settingSendCookies: boolean;
settingStoreCookies: boolean; settingStoreCookies: boolean;
+11 -1
View File
@@ -20,6 +20,7 @@ pub async fn ws_connect(
headers: HeaderMap<HeaderValue>, headers: HeaderMap<HeaderValue>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response)> { ) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response)> {
info!("Connecting to WS {url}"); info!("Connecting to WS {url}");
let tls_config = get_tls_config(validate_certificates, WITH_ALPN, client_cert.clone())?; let tls_config = get_tls_config(validate_certificates, WITH_ALPN, client_cert.clone())?;
@@ -34,7 +35,7 @@ pub async fn ws_connect(
let (stream, response) = connect_async_tls_with_config( let (stream, response) = connect_async_tls_with_config(
req, req,
Some(WebSocketConfig::default()), Some(websocket_config(request_message_size)),
false, false,
Some(Connector::Rustls(Arc::new(tls_config))), Some(Connector::Rustls(Arc::new(tls_config))),
) )
@@ -48,3 +49,12 @@ pub async fn ws_connect(
Ok((stream, response)) Ok((stream, response))
} }
fn websocket_config(request_message_size: i32) -> WebSocketConfig {
let max_message_size = message_size_limit(request_message_size);
WebSocketConfig::default().max_message_size(max_message_size).max_frame_size(max_message_size)
}
pub(crate) fn message_size_limit(setting: i32) -> Option<usize> {
setting.try_into().ok().filter(|limit| *limit > 0)
}
+2 -2
View File
@@ -4,7 +4,7 @@ use tokio_tungstenite::tungstenite;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum Error { pub enum Error {
#[error("WebSocket error: {0}")] #[error("{0}")]
WebSocketErr(#[from] tungstenite::Error), WebSocketErr(#[from] tungstenite::Error),
#[error(transparent)] #[error(transparent)]
@@ -16,7 +16,7 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
TlsError(#[from] yaak_tls::error::Error), TlsError(#[from] yaak_tls::error::Error),
#[error("WebSocket error: {0}")] #[error("{0}")]
GenericError(String), GenericError(String),
} }
+28 -8
View File
@@ -1,4 +1,5 @@
use crate::connect::ws_connect; use crate::connect::{message_size_limit, ws_connect};
use crate::error::Error::GenericError;
use crate::error::Result; use crate::error::Result;
use futures_util::stream::SplitSink; use futures_util::stream::SplitSink;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
@@ -15,10 +16,16 @@ use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use yaak_tls::ClientCertificateConfig; use yaak_tls::ClientCertificateConfig;
type WebsocketSink = SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>;
struct WebsocketConnection {
max_message_size: Option<usize>,
sink: WebsocketSink,
}
#[derive(Clone)] #[derive(Clone)]
pub struct WebsocketManager { pub struct WebsocketManager {
connections: connections: Arc<Mutex<HashMap<String, WebsocketConnection>>>,
Arc<Mutex<HashMap<String, SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>>,
read_tasks: Arc<Mutex<HashMap<String, tokio::task::JoinHandle<()>>>>, read_tasks: Arc<Mutex<HashMap<String, tokio::task::JoinHandle<()>>>>,
} }
@@ -35,14 +42,20 @@ impl WebsocketManager {
receive_tx: mpsc::Sender<Message>, receive_tx: mpsc::Sender<Message>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<Response> { ) -> Result<Response> {
let tx = receive_tx.clone(); let tx = receive_tx.clone();
let max_message_size = message_size_limit(request_message_size);
let (stream, response) = let (stream, response) =
ws_connect(url, headers, validate_certificates, client_cert).await?; ws_connect(url, headers, validate_certificates, client_cert, request_message_size)
.await?;
let (write, mut read) = stream.split(); let (write, mut read) = stream.split();
self.connections.lock().await.insert(id.to_string(), write); self.connections
.lock()
.await
.insert(id.to_string(), WebsocketConnection { max_message_size, sink: write });
let handle = { let handle = {
let connection_id = id.to_string(); let connection_id = id.to_string();
@@ -70,13 +83,20 @@ impl WebsocketManager {
} }
pub async fn send(&mut self, id: &str, msg: Message) -> Result<()> { pub async fn send(&mut self, id: &str, msg: Message) -> Result<()> {
debug!("Send websocket message {msg:?}");
let mut connections = self.connections.lock().await; let mut connections = self.connections.lock().await;
let connection = match connections.get_mut(id) { let connection = match connections.get_mut(id) {
None => return Ok(()), None => return Ok(()),
Some(c) => c, Some(c) => c,
}; };
connection.send(msg).await?; if let Some(limit) = connection.max_message_size {
let message_size = msg.len();
if message_size > limit {
return Err(GenericError(format!(
"WebSocket message too large: found {message_size} bytes, the limit is {limit} bytes"
)));
}
}
connection.sink.send(msg).await?;
Ok(()) Ok(())
} }
@@ -84,7 +104,7 @@ impl WebsocketManager {
info!("Closing websocket"); info!("Closing websocket");
if let Some(mut connection) = self.connections.lock().await.remove(id) { if let Some(mut connection) = self.connections.lock().await.remove(id) {
// Wait a maximum of 1 second for the connection to close // Wait a maximum of 1 second for the connection to close
if let Err(e) = connection.close().await { if let Err(e) = connection.sink.close().await {
warn!("Failed to close websocket connection {e:?}"); warn!("Failed to close websocket connection {e:?}");
}; };
} }
+874 -535
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -121,14 +121,13 @@
"nodejs-file-downloader": "^4.13.0", "nodejs-file-downloader": "^4.13.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"vite-plus": "^0.1.20", "vite-plus": "^0.2.1",
"vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.20" "vitest": "^4.1.9"
}, },
"overrides": { "overrides": {
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1"
"vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.20"
}, },
"packageManager": "npm@11.11.1" "packageManager": "npm@11.11.1"
} }
+6 -1
View File
@@ -108,6 +108,7 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting; settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting; settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type GraphQlIntrospection = { export type GraphQlIntrospection = {
@@ -183,6 +184,7 @@ export type GrpcRequest = {
*/ */
url: string; url: string;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type HttpRequest = { export type HttpRequest = {
@@ -426,7 +428,8 @@ export type WebsocketEvent = {
messageType: WebsocketEventType; messageType: WebsocketEventType;
}; };
export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text"; export type WebsocketEventType =
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketRequest = { export type WebsocketRequest = {
model: "websocket_request"; model: "websocket_request";
@@ -450,6 +453,7 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting; settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting; settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type Workspace = { export type Workspace = {
@@ -466,6 +470,7 @@ export type Workspace = {
settingValidateCertificates: boolean; settingValidateCertificates: boolean;
settingFollowRedirects: boolean; settingFollowRedirects: boolean;
settingRequestTimeout: number; settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>; settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean; settingSendCookies: boolean;
settingStoreCookies: boolean; settingStoreCookies: boolean;
+1 -1
View File
@@ -6,7 +6,7 @@
"build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs" "build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"
}, },
"dependencies": { "dependencies": {
"ws": "^8.20.1" "ws": "^8.21.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.13", "@types/node": "^24.0.13",
+3
View File
@@ -166,6 +166,9 @@ function toastColorVariables(theme: Theme, color: YaakColor): CSSVariables {
function bannerColorVariables(theme: Theme, color: YaakColor): CSSVariables { function bannerColorVariables(theme: Theme, color: YaakColor): CSSVariables {
return completeFullColorVariables(theme, { return completeFullColorVariables(theme, {
text: color.desaturate(0.5).lift(0.12).css(),
textSubtle: color.desaturate(0.58).lift(0.04).translucify(0.04).css(),
textSubtlest: color.desaturate(0.65).translucify(0.18).css(),
surface: color.translucify(0.95).css(), surface: color.translucify(0.95).css(),
surfaceHighlight: color.translucify(0.85).css(), surfaceHighlight: color.translucify(0.85).css(),
border: color.lift(0.3).translucify(0.8).css(), border: color.lift(0.3).translucify(0.8).css(),
+1 -1
View File
@@ -17,7 +17,7 @@
"@hono/mcp": "^0.2.3", "@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.13", "@hono/node-server": "^1.19.13",
"@modelcontextprotocol/sdk": "^1.26.0", "@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.12.14", "hono": "^4.12.25",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
+3
View File
@@ -69,6 +69,9 @@ const config = JSON.stringify({
const normalizedAdditionalArgs = []; 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 === "--") {
continue;
}
if (arg === "--config" && i + 1 < additionalArgs.length) { if (arg === "--config" && i + 1 < additionalArgs.length) {
const value = additionalArgs[i + 1]; const value = additionalArgs[i + 1];
const isInlineJson = value.trimStart().startsWith("{"); const isInlineJson = value.trimStart().startsWith("{");