mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-06-30 10:01:42 +02:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9be57e6d9 | |||
| c3aecfdc0c | |||
| 09adcda2d9 | |||
| 18b983bfe5 | |||
| 9ffd8d4810 | |||
| 55d0066efd | |||
| 1de0a5942c | |||
| fd0ca6d455 | |||
| 84b89e2708 | |||
| 7db3e9b879 |
Generated
+2
@@ -10052,6 +10052,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"yaak-core",
|
||||
"yaak-crypto",
|
||||
"yaak-http",
|
||||
"yaak-models",
|
||||
@@ -10182,6 +10183,7 @@ dependencies = [
|
||||
"webbrowser",
|
||||
"yaak",
|
||||
"yaak-api",
|
||||
"yaak-core",
|
||||
"yaak-crypto",
|
||||
"yaak-http",
|
||||
"yaak-models",
|
||||
|
||||
@@ -130,7 +130,7 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
|
||||
return key !== nextCookieKey;
|
||||
});
|
||||
|
||||
patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
|
||||
void patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
|
||||
setSelectedCookieKey(nextCookieKey);
|
||||
setEditingCookieKey(null);
|
||||
setDraftCookie(null);
|
||||
@@ -210,7 +210,7 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
|
||||
setEditingCookieKey(null);
|
||||
setDraftCookie(null);
|
||||
setDraftExpiresInput("");
|
||||
patchModel(cookieJar, { cookies: [] });
|
||||
void patchModel(cookieJar, { cookies: [] });
|
||||
}}
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
@@ -276,7 +276,7 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
|
||||
setDraftCookie(null);
|
||||
setDraftExpiresInput("");
|
||||
}
|
||||
patchModel(cookieJar, {
|
||||
void patchModel(cookieJar, {
|
||||
cookies: cookieJar.cookies.filter(
|
||||
(c2: Cookie) => cookieKey(c2) !== key,
|
||||
),
|
||||
@@ -570,6 +570,8 @@ function CookieTextInput({
|
||||
return (
|
||||
<input
|
||||
autoFocus={autoFocus}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={cookieInputClassName}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
@@ -585,6 +587,8 @@ function CookieTextInput({
|
||||
function CookieTextarea({ onChange, value }: { onChange: (value: string) => void; value: string }) {
|
||||
return (
|
||||
<textarea
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={classNames(cookieInputClassName, "min-h-[5rem] resize-y")}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
value={value}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
modelSupportsSetting,
|
||||
type RequestSettingDefinition,
|
||||
SETTING_FOLLOW_REDIRECTS,
|
||||
SETTING_REQUEST_MESSAGE_SIZE,
|
||||
SETTING_REQUEST_TIMEOUT,
|
||||
SETTING_SEND_COOKIES,
|
||||
SETTING_STORE_COOKIES,
|
||||
@@ -22,21 +23,45 @@ import { Checkbox } from "./core/Checkbox";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import {
|
||||
SettingOverrideRow,
|
||||
SettingRow,
|
||||
SettingRowBoolean,
|
||||
SettingRowNumber,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} 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 {
|
||||
showSectionTitles?: boolean;
|
||||
model: ModelWithSettings;
|
||||
}
|
||||
|
||||
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
||||
type ModelWithSettings =
|
||||
| Workspace
|
||||
| Folder
|
||||
| HttpRequest
|
||||
| WebsocketRequest
|
||||
| GrpcRequest;
|
||||
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
|
||||
type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
||||
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest;
|
||||
type ModelWithTlsSettings =
|
||||
| Workspace
|
||||
| Folder
|
||||
| HttpRequest
|
||||
| WebsocketRequest
|
||||
| GrpcRequest;
|
||||
type ModelWithCookieSettings =
|
||||
| Workspace
|
||||
| Folder
|
||||
| HttpRequest
|
||||
| WebsocketRequest;
|
||||
type ModelWithMessageSizeSettings =
|
||||
| Workspace
|
||||
| Folder
|
||||
| WebsocketRequest
|
||||
| GrpcRequest;
|
||||
type BooleanSetting = boolean | InheritedBoolSetting;
|
||||
type IntegerSetting = number | InheritedIntSetting;
|
||||
type CookieSettingsPatch = {
|
||||
@@ -50,12 +75,19 @@ type HttpSettingsPatch = {
|
||||
type TlsSettingsPatch = {
|
||||
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 supportsHttpSettings = modelSupportsHttpSettings(model);
|
||||
const supportsCookieSettings = modelSupportsCookieSettings(model);
|
||||
const supportsTlsSettings = modelSupportsTlsSettings(model);
|
||||
const supportsMessageSizeSettings = modelSupportsMessageSizeSettings(model);
|
||||
|
||||
return (
|
||||
<SettingsList className="space-y-8">
|
||||
@@ -77,6 +109,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
|
||||
settingDefinition={SETTING_VALIDATE_CERTIFICATES}
|
||||
setting={model.settingValidateCertificates}
|
||||
@@ -110,7 +158,9 @@ export function ModelSettingsEditor({ model, showSectionTitles = false }: Props)
|
||||
</SettingsSection>
|
||||
)}
|
||||
{supportsCookieSettings && (
|
||||
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
|
||||
<SettingsSection
|
||||
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
|
||||
>
|
||||
<BooleanSettingRow
|
||||
settingDefinition={SETTING_SEND_COOKIES}
|
||||
setting={model.settingSendCookies}
|
||||
@@ -158,46 +208,93 @@ export function countOverriddenSettings(model: ModelWithSettings) {
|
||||
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
|
||||
}
|
||||
|
||||
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
|
||||
.length;
|
||||
if (modelSupportsMessageSizeSettings(model)) {
|
||||
settings.push(model.settingRequestMessageSize);
|
||||
}
|
||||
|
||||
return settings.filter(
|
||||
(setting) => isInheritedSetting(setting) && setting.enabled === true,
|
||||
).length;
|
||||
}
|
||||
|
||||
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
|
||||
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
|
||||
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
|
||||
if (model.model === "http_request") return patchModel(model, patch as Partial<HttpRequest>);
|
||||
function patchCookieSettings(
|
||||
model: ModelWithCookieSettings,
|
||||
patch: Partial<CookieSettingsPatch>,
|
||||
) {
|
||||
if (model.model === "workspace")
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
if (model.model === "folder")
|
||||
return patchModel(model, patch as Partial<Folder>);
|
||||
if (model.model === "http_request")
|
||||
return patchModel(model, patch as Partial<HttpRequest>);
|
||||
if (model.model === "websocket_request")
|
||||
return patchModel(model, patch as Partial<WebsocketRequest>);
|
||||
throw new Error("Unsupported cookie settings model");
|
||||
}
|
||||
|
||||
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
|
||||
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
|
||||
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
|
||||
function patchHttpSettings(
|
||||
model: ModelWithHttpSettings,
|
||||
patch: Partial<HttpSettingsPatch>,
|
||||
) {
|
||||
if (model.model === "workspace")
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
if (model.model === "folder")
|
||||
return patchModel(model, patch as Partial<Folder>);
|
||||
return patchModel(model, patch as Partial<HttpRequest>);
|
||||
}
|
||||
|
||||
function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
|
||||
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
|
||||
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
|
||||
if (model.model === "http_request") return patchModel(model, patch as Partial<HttpRequest>);
|
||||
function patchTlsSettings(
|
||||
model: ModelWithTlsSettings,
|
||||
patch: Partial<TlsSettingsPatch>,
|
||||
) {
|
||||
if (model.model === "workspace")
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
if (model.model === "folder")
|
||||
return patchModel(model, patch as Partial<Folder>);
|
||||
if (model.model === "http_request")
|
||||
return patchModel(model, patch as Partial<HttpRequest>);
|
||||
if (model.model === "websocket_request")
|
||||
return patchModel(model, patch as Partial<WebsocketRequest>);
|
||||
return patchModel(model, patch as Partial<GrpcRequest>);
|
||||
}
|
||||
|
||||
function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings {
|
||||
function patchMessageSizeSettings(
|
||||
model: ModelWithMessageSizeSettings,
|
||||
patch: Partial<MessageSizeSettingsPatch>,
|
||||
) {
|
||||
if (model.model === "workspace")
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
if (model.model === "folder")
|
||||
return patchModel(model, patch as Partial<Folder>);
|
||||
if (model.model === "websocket_request")
|
||||
return patchModel(model, patch as Partial<WebsocketRequest>);
|
||||
return patchModel(model, patch as Partial<GrpcRequest>);
|
||||
}
|
||||
|
||||
function modelSupportsHttpSettings(
|
||||
model: ModelWithSettings,
|
||||
): model is ModelWithHttpSettings {
|
||||
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);
|
||||
}
|
||||
|
||||
function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings {
|
||||
function modelSupportsTlsSettings(
|
||||
model: ModelWithSettings,
|
||||
): model is ModelWithTlsSettings {
|
||||
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
|
||||
}
|
||||
|
||||
function modelSupportsMessageSizeSettings(
|
||||
model: ModelWithSettings,
|
||||
): model is ModelWithMessageSizeSettings {
|
||||
return modelSupportsSetting(model, SETTING_REQUEST_MESSAGE_SIZE);
|
||||
}
|
||||
|
||||
function BooleanSettingRow({
|
||||
inheritedValue,
|
||||
setting,
|
||||
@@ -211,7 +308,11 @@ function BooleanSettingRow({
|
||||
}) {
|
||||
const inherited = isInheritedSetting(setting);
|
||||
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) {
|
||||
return (
|
||||
@@ -250,12 +351,18 @@ function IntegerSettingRow({
|
||||
}: {
|
||||
inheritedValue: number;
|
||||
setting: IntegerSetting;
|
||||
settingDefinition: RequestSettingDefinition<"settingRequestTimeout">;
|
||||
settingDefinition: RequestSettingDefinition<
|
||||
"settingRequestTimeout" | "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 value = inherited
|
||||
? overridden
|
||||
? setting.value
|
||||
: inheritedValue
|
||||
: setting;
|
||||
|
||||
if (!inherited) {
|
||||
return (
|
||||
@@ -300,6 +407,99 @@ 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 = formatMegabytes(settingDefinition.defaultValue);
|
||||
|
||||
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 (
|
||||
<PlainInput
|
||||
hideLabel
|
||||
name={name}
|
||||
label={label}
|
||||
size="sm"
|
||||
type="number"
|
||||
step={0.1}
|
||||
placeholder={placeholder}
|
||||
defaultValue={value}
|
||||
containerClassName="!w-48"
|
||||
validate={isValidMegabytes}
|
||||
rightSlot={<span className="px-2 text-xs text-text-subtle">MB</span>}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function isInheritedSetting<T>(
|
||||
setting: T | { enabled?: boolean; value: T },
|
||||
): setting is { enabled?: boolean; value: T } {
|
||||
@@ -308,7 +508,7 @@ function isInheritedSetting<T>(
|
||||
|
||||
function resolveInheritedValue(
|
||||
ancestors: (Folder | Workspace)[],
|
||||
key: "settingRequestTimeout",
|
||||
key: "settingRequestTimeout" | "settingRequestMessageSize",
|
||||
fallback: IntegerSetting,
|
||||
): number;
|
||||
function resolveInheritedValue(
|
||||
@@ -338,10 +538,36 @@ function resolveInheritedValue(
|
||||
type WorkspaceSettings = Pick<
|
||||
Workspace,
|
||||
| "settingFollowRedirects"
|
||||
| "settingRequestMessageSize"
|
||||
| "settingRequestTimeout"
|
||||
| "settingSendCookies"
|
||||
| "settingStoreCookies"
|
||||
| "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 isValidMegabytes(value: string) {
|
||||
if (value === "") return true;
|
||||
const megabytes = Number(value);
|
||||
return (
|
||||
Number.isFinite(megabytes) &&
|
||||
megabytes >= 0 &&
|
||||
megabytes <= MAX_MESSAGE_SIZE_MB
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useAuthTab } from "../hooks/useAuthTab";
|
||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parseBulkPairLine } from "./BulkPairEditor";
|
||||
|
||||
describe("parseBulkPairLine", () => {
|
||||
test("parses colon-space pairs as name and value", () => {
|
||||
expect(parseBulkPairLine("foo: bar")).toMatchObject({
|
||||
enabled: true,
|
||||
name: "foo",
|
||||
value: "bar",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves colon-without-space lines as a name with an empty value", () => {
|
||||
expect(parseBulkPairLine("foo:bar")).toMatchObject({
|
||||
enabled: true,
|
||||
name: "foo:bar",
|
||||
value: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves malformed lines instead of dropping their contents", () => {
|
||||
expect(parseBulkPairLine("not a pair")).toMatchObject({
|
||||
enabled: true,
|
||||
name: "not a pair",
|
||||
value: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("unescapes newlines in parsed values", () => {
|
||||
expect(parseBulkPairLine("foo: bar\\nbaz")).toMatchObject({
|
||||
enabled: true,
|
||||
name: "foo",
|
||||
value: "bar\nbaz",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ export function BulkPairEditor({
|
||||
const pairsText = useMemo(() => {
|
||||
return pairs
|
||||
.filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
|
||||
.map(pairToLine)
|
||||
.map(formatBulkPairLine)
|
||||
.join("\n");
|
||||
}, [pairs]);
|
||||
|
||||
@@ -26,7 +26,7 @@ export function BulkPairEditor({
|
||||
const pairs = text
|
||||
.split("\n")
|
||||
.filter((l: string) => l.trim())
|
||||
.map(lineToPair);
|
||||
.map(parseBulkPairLine);
|
||||
onChange(pairs);
|
||||
},
|
||||
[onChange],
|
||||
@@ -47,16 +47,16 @@ export function BulkPairEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function pairToLine(pair: Pair) {
|
||||
export function formatBulkPairLine(pair: Pair) {
|
||||
const value = pair.value.replaceAll("\n", "\\n");
|
||||
return `${pair.name}: ${value}`;
|
||||
}
|
||||
|
||||
function lineToPair(line: string): PairWithId {
|
||||
const [, name, value] = line.match(/^(:?[^:]+):\s+(.*)$/) ?? [];
|
||||
export function parseBulkPairLine(line: string): PairWithId {
|
||||
const [, name, value] = line.match(/^([^:]+):\s+(.*)$/) ?? [];
|
||||
return {
|
||||
enabled: true,
|
||||
name: (name ?? "").trim(),
|
||||
name: (name ?? line).trim(),
|
||||
value: (value ?? "").replaceAll("\\n", "\n").trim(),
|
||||
id: generateId(),
|
||||
};
|
||||
|
||||
@@ -580,6 +580,10 @@ function getExtensions({
|
||||
|
||||
return [
|
||||
...baseExtensions, // Must be first
|
||||
EditorView.contentAttributes.of({
|
||||
autocapitalize: "off",
|
||||
autocorrect: "off",
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
focus: () => {
|
||||
onFocus.current?.();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@top pairs { (Key Sep Value "\n")* }
|
||||
|
||||
@tokens {
|
||||
Sep { ":" }
|
||||
Sep { ":" $[ \t]+ }
|
||||
Key { ":"? ![:]+ }
|
||||
Value { ![\n]+ }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parser } from "./pairs";
|
||||
|
||||
function getNodeNames(input: string): string[] {
|
||||
const tree = parser.parse(input);
|
||||
const nodes: string[] = [];
|
||||
const cursor = tree.cursor();
|
||||
do {
|
||||
if (cursor.name !== "pairs") {
|
||||
nodes.push(cursor.name);
|
||||
}
|
||||
} while (cursor.next());
|
||||
return nodes;
|
||||
}
|
||||
|
||||
describe("pairs grammar", () => {
|
||||
test("parses colon-space pairs with a value", () => {
|
||||
expect(getNodeNames("foo: bar\n")).toEqual(["Key", "Sep", "Value"]);
|
||||
});
|
||||
|
||||
test("does not parse colon-without-space as a value", () => {
|
||||
const nodes = getNodeNames("foo:bar\n");
|
||||
|
||||
expect(nodes).not.toContain("Value");
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ export const parser = LRParser.deserialize({
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 1,
|
||||
tokenData:
|
||||
"$]VRVOYhYZ#[Z![h![!]#o!];'Sh;'S;=`#U<%lOhToVQPSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!US!rSSSOY!mZ;'S!m;'S;=`#O<%lO!mS#RP;=`<%l!mT#XP;=`<%lhR#cSVQQPO![!U!];'S!U;'S;=`!g<%lO!UV#vVRQSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOh",
|
||||
"%]VRVOYhYZ#[Z![h![!]#o!];'Sh;'S;=`#U<%lOhToVQPSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!US!rSSSOY!mZ;'S!m;'S;=`#O<%lO!mS#RP;=`<%l!mT#XP;=`<%lhR#cSVQQPO![!U!];'S!U;'S;=`!g<%lO!UV#tYSSOXhXY$dYZ!UZphpq$dq![h![!]!m!];'Sh;'S;=`#U<%lOhV$mYQPRQSSOXhXY$dYZ!UZphpq$dq![h![!]!m!];'Sh;'S;=`#U<%lOh",
|
||||
tokenizers: [0, 1, 2],
|
||||
topRules: { pairs: [0, 1] },
|
||||
tokenPrec: 0,
|
||||
|
||||
@@ -55,6 +55,8 @@ export function KeyValueRow({
|
||||
const textToCopy =
|
||||
copyText ??
|
||||
(typeof children === "string" || typeof children === "number" ? `${children}` : null);
|
||||
const copyTitle =
|
||||
typeof label === "string" || typeof label === "number" ? `Copy ${label}` : "Copy value";
|
||||
const resolvedRightSlot =
|
||||
rightSlot ??
|
||||
(enableCopy && textToCopy != null ? (
|
||||
@@ -62,7 +64,7 @@ export function KeyValueRow({
|
||||
text={textToCopy}
|
||||
className="text-text-subtle"
|
||||
size="2xs"
|
||||
title={`Copy ${label}`}
|
||||
title={copyTitle}
|
||||
iconSize="sm"
|
||||
/>
|
||||
) : null);
|
||||
|
||||
@@ -64,6 +64,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
||||
required,
|
||||
rightSlot,
|
||||
size = "md",
|
||||
step,
|
||||
tint,
|
||||
type = "text",
|
||||
validate,
|
||||
@@ -210,6 +211,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
required={required}
|
||||
step={step}
|
||||
placeholder={placeholder}
|
||||
onKeyDownCapture={onKeyDownCapture}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useGitFileDiffForCommit, useGitLog, useGitMutations } from "@yaakapp-internal/git";
|
||||
import type { GitCommit } from "@yaakapp-internal/git";
|
||||
import { InlineCode, SplitLayout } from "@yaakapp-internal/ui";
|
||||
import { SplitLayout } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, Icon, IconButton, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
|
||||
import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { modelToYaml } from "../../lib/diffYaml";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Appearance } from "../lib/theme/appearance";
|
||||
import { getCSSAppearance, subscribeToPreferredAppearance } from "../lib/theme/appearance";
|
||||
import type { Appearance } from "@yaakapp-internal/theme";
|
||||
import { getCSSAppearance, subscribeToPreferredAppearance } from "@yaakapp-internal/theme";
|
||||
|
||||
export function usePreferredAppearance() {
|
||||
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance());
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import { resolveAppearance } from "@yaakapp-internal/theme";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { resolveAppearance } from "../lib/theme/appearance";
|
||||
import { usePreferredAppearance } from "./usePreferredAppearance";
|
||||
|
||||
export function useResolvedAppearance() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { getResolvedTheme, getThemes } from "../lib/theme/themes";
|
||||
import { getResolvedTheme, getThemes } from "../lib/themes";
|
||||
import { usePluginsKey } from "./usePlugins";
|
||||
import { usePreferredAppearance } from "./usePreferredAppearance";
|
||||
|
||||
|
||||
@@ -1,40 +1,32 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { getModel } from "@yaakapp-internal/models";
|
||||
import { flushAllModelWrites } from "@yaakapp-internal/models";
|
||||
import { invokeCmd } from "../lib/tauri";
|
||||
import { getActiveCookieJar } from "./useActiveCookieJar";
|
||||
import { getActiveEnvironment } from "./useActiveEnvironment";
|
||||
import { createFastMutation, useFastMutation } from "./useFastMutation";
|
||||
|
||||
async function sendAnyHttpRequestById(id: string | null): Promise<HttpResponse | null> {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await flushAllModelWrites();
|
||||
|
||||
return invokeCmd("cmd_send_http_request", {
|
||||
requestId: id,
|
||||
environmentId: getActiveEnvironment()?.id,
|
||||
cookieJarId: getActiveCookieJar()?.id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendAnyHttpRequest() {
|
||||
return useFastMutation<HttpResponse | null, string, string | null>({
|
||||
mutationKey: ["send_any_request"],
|
||||
mutationFn: async (id) => {
|
||||
const request = getModel("http_request", id ?? "n/a");
|
||||
if (request == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return invokeCmd("cmd_send_http_request", {
|
||||
request,
|
||||
environmentId: getActiveEnvironment()?.id,
|
||||
cookieJarId: getActiveCookieJar()?.id,
|
||||
});
|
||||
},
|
||||
mutationFn: sendAnyHttpRequestById,
|
||||
});
|
||||
}
|
||||
|
||||
export const sendAnyHttpRequest = createFastMutation<HttpResponse | null, string, string | null>({
|
||||
mutationKey: ["send_any_request"],
|
||||
mutationFn: async (id) => {
|
||||
const request = getModel("http_request", id ?? "n/a");
|
||||
if (request == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return invokeCmd("cmd_send_http_request", {
|
||||
request,
|
||||
environmentId: getActiveEnvironment()?.id,
|
||||
cookieJarId: getActiveCookieJar()?.id,
|
||||
});
|
||||
},
|
||||
mutationFn: sendAnyHttpRequestById,
|
||||
});
|
||||
|
||||
@@ -44,6 +44,19 @@ export function initGlobalListeners() {
|
||||
color: "danger",
|
||||
timeout: null,
|
||||
message: `Failed to load plugin "${name}": ${err}`,
|
||||
action: ({ hide }) => (
|
||||
<Button
|
||||
size="xs"
|
||||
color="danger"
|
||||
variant="border"
|
||||
onClick={() => {
|
||||
hide();
|
||||
openSettings.mutate("plugins:installed");
|
||||
}}
|
||||
>
|
||||
Manage Plugins
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ type ModelType = AnyModel["model"];
|
||||
type WorkspaceRequestSettings = Pick<
|
||||
Workspace,
|
||||
| "settingFollowRedirects"
|
||||
| "settingRequestMessageSize"
|
||||
| "settingRequestTimeout"
|
||||
| "settingSendCookies"
|
||||
| "settingStoreCookies"
|
||||
@@ -17,7 +18,9 @@ type ModelTypeWithSetting<K extends RequestSettingKey> = {
|
||||
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
|
||||
}[ModelType];
|
||||
|
||||
export type RequestSettingDefinition<K extends RequestSettingKey = RequestSettingKey> = {
|
||||
export type RequestSettingDefinition<
|
||||
K extends RequestSettingKey = RequestSettingKey,
|
||||
> = {
|
||||
defaultValue: WorkspaceRequestSettings[K];
|
||||
description: string;
|
||||
modelKey: K;
|
||||
@@ -41,11 +44,26 @@ export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
|
||||
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({
|
||||
defaultValue: true,
|
||||
description: "When disabled, skip validation of server certificates.",
|
||||
modelKey: "settingValidateCertificates",
|
||||
models: ["workspace", "folder", "http_request", "websocket_request", "grpc_request"],
|
||||
models: [
|
||||
"workspace",
|
||||
"folder",
|
||||
"http_request",
|
||||
"websocket_request",
|
||||
"grpc_request",
|
||||
],
|
||||
title: "Validate TLS certificates",
|
||||
});
|
||||
|
||||
@@ -59,7 +77,8 @@ export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
|
||||
|
||||
export const SETTING_SEND_COOKIES = defineRequestSetting({
|
||||
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",
|
||||
models: ["workspace", "folder", "http_request", "websocket_request"],
|
||||
title: "Automatically send cookies",
|
||||
@@ -67,7 +86,8 @@ export const SETTING_SEND_COOKIES = defineRequestSetting({
|
||||
|
||||
export const SETTING_STORE_COOKIES = defineRequestSetting({
|
||||
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",
|
||||
models: ["workspace", "folder", "http_request", "websocket_request"],
|
||||
title: "Automatically store cookies",
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export type { Appearance } from "@yaakapp-internal/theme";
|
||||
export {
|
||||
getCSSAppearance,
|
||||
getWindowAppearance,
|
||||
resolveAppearance,
|
||||
subscribeToPreferredAppearance,
|
||||
subscribeToWindowAppearanceChange,
|
||||
} from "@yaakapp-internal/theme";
|
||||
@@ -1,9 +0,0 @@
|
||||
export type { YaakColorKey, YaakColors, YaakTheme } from "@yaakapp-internal/theme";
|
||||
export {
|
||||
addThemeStylesToDocument,
|
||||
applyThemeToDocument,
|
||||
completeTheme,
|
||||
getThemeCSS,
|
||||
indent,
|
||||
setThemeOnDocument,
|
||||
} from "@yaakapp-internal/theme";
|
||||
@@ -1 +0,0 @@
|
||||
export { YaakColor } from "@yaakapp-internal/theme";
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { GetThemesResponse } from "@yaakapp-internal/plugins";
|
||||
import { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
|
||||
import { invokeCmd } from "../tauri";
|
||||
import type { Appearance } from "./appearance";
|
||||
import { resolveAppearance } from "./appearance";
|
||||
|
||||
export { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
|
||||
import {
|
||||
defaultDarkTheme,
|
||||
defaultLightTheme,
|
||||
resolveAppearance,
|
||||
type Appearance,
|
||||
} from "@yaakapp-internal/theme";
|
||||
import { invokeCmd } from "./tauri";
|
||||
|
||||
export async function getThemes() {
|
||||
const themes = (await invokeCmd<GetThemesResponse[]>("cmd_get_themes")).flatMap((t) => t.themes);
|
||||
@@ -2,11 +2,14 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { setWindowTheme } from "@yaakapp-internal/mac-window";
|
||||
import type { ModelPayload } from "@yaakapp-internal/models";
|
||||
import type { Appearance } from "@yaakapp-internal/theme";
|
||||
import {
|
||||
applyThemeToDocument,
|
||||
getCSSAppearance,
|
||||
subscribeToPreferredAppearance,
|
||||
} from "@yaakapp-internal/theme";
|
||||
import { getSettings } from "./lib/settings";
|
||||
import type { Appearance } from "./lib/theme/appearance";
|
||||
import { getCSSAppearance, subscribeToPreferredAppearance } from "./lib/theme/appearance";
|
||||
import { getResolvedTheme } from "./lib/theme/themes";
|
||||
import { applyThemeToDocument } from "@yaakapp-internal/theme";
|
||||
import { getResolvedTheme } from "./lib/themes";
|
||||
|
||||
// NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want
|
||||
// a good appearance guess so we're not waiting too long
|
||||
|
||||
@@ -42,6 +42,7 @@ webbrowser = "1"
|
||||
zip = "4"
|
||||
yaak = { workspace = true }
|
||||
yaak-api = { workspace = true }
|
||||
yaak-core = { workspace = true }
|
||||
yaak-crypto = { workspace = true }
|
||||
yaak-http = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
|
||||
@@ -42,6 +42,12 @@ pub enum Commands {
|
||||
/// Authentication commands
|
||||
Auth(AuthArgs),
|
||||
|
||||
/// Import API data from Yaak, OpenAPI, Postman, Insomnia, Swagger, or cURL
|
||||
Import(ImportArgs),
|
||||
|
||||
/// Export Yaak workspace data
|
||||
Export(ExportArgs),
|
||||
|
||||
/// Plugin development and publishing commands
|
||||
Plugin(PluginArgs),
|
||||
|
||||
@@ -92,6 +98,34 @@ pub struct SendArgs {
|
||||
pub fail_fast: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ImportArgs {
|
||||
/// Path to the file to import
|
||||
pub file: PathBuf,
|
||||
|
||||
/// Existing workspace ID to import into when supported by the importer
|
||||
#[arg(long = "workspace-id", value_name = "WORKSPACE_ID")]
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ExportArgs {
|
||||
/// Path to write the Yaak export JSON file
|
||||
pub file: PathBuf,
|
||||
|
||||
/// Workspace IDs to export (defaults to the only workspace when exactly one exists)
|
||||
#[arg(value_name = "WORKSPACE_ID")]
|
||||
pub workspace_ids: Vec<String>,
|
||||
|
||||
/// Export all workspaces
|
||||
#[arg(long, conflicts_with = "workspace_ids")]
|
||||
pub all: bool,
|
||||
|
||||
/// Include private environments in the export
|
||||
#[arg(long)]
|
||||
pub include_private_environments: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
#[command(disable_help_subcommand = true)]
|
||||
pub struct CookieJarArgs {
|
||||
@@ -447,6 +481,10 @@ pub enum PluginCommands {
|
||||
/// Install a plugin from a local directory or from the registry
|
||||
Install(InstallPluginArgs),
|
||||
|
||||
/// Generate plugin metadata for the registry
|
||||
#[command(hide = true)]
|
||||
Metadata(PluginPathArg),
|
||||
|
||||
/// Publish a Yaak plugin version to the plugin registry
|
||||
Publish(PluginPathArg),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
use crate::cli::{ExportArgs, ImportArgs};
|
||||
use crate::context::CliContext;
|
||||
use crate::utils::workspace::resolve_workspace_id;
|
||||
use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use yaak::export::{self, ExportDataParams};
|
||||
use yaak::import;
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_plugins::events::{ImportResources, PluginContext};
|
||||
|
||||
type CommandResult<T = ()> = std::result::Result<T, String>;
|
||||
|
||||
pub async fn run_import(ctx: &CliContext, args: ImportArgs) -> i32 {
|
||||
match import(ctx, args).await {
|
||||
Ok(result) => {
|
||||
println!("Imported {}", format_counts(&result));
|
||||
0
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Error: {error}");
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_export(ctx: &CliContext, args: ExportArgs) -> i32 {
|
||||
match export(ctx, args) {
|
||||
Ok(count) => {
|
||||
println!("Exported {count} workspace(s)");
|
||||
0
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Error: {error}");
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsertResult> {
|
||||
if let Some(workspace_id) = args.workspace_id.as_deref() {
|
||||
ctx.db()
|
||||
.get_workspace(workspace_id)
|
||||
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
|
||||
}
|
||||
|
||||
let file_contents = read_import_file(&args.file)?;
|
||||
let plugin_context = PluginContext::new(None, args.workspace_id.clone());
|
||||
let plugin_manager = ctx.plugin_manager();
|
||||
let import_result = plugin_manager
|
||||
.import_data(&plugin_context, &file_contents)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||
let resources = import_result.resources;
|
||||
let workspace_id = args.workspace_id;
|
||||
if workspace_id.is_none() && resources_need_current_workspace(&resources) {
|
||||
return Err(
|
||||
"This import requires a workspace context. Provide --workspace-id <WORKSPACE_ID>."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let workspace_context = WorkspaceContext {
|
||||
workspace_id,
|
||||
environment_id: None,
|
||||
cookie_jar_id: None,
|
||||
request_id: None,
|
||||
};
|
||||
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources)
|
||||
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
fn export(ctx: &CliContext, args: ExportArgs) -> CommandResult<usize> {
|
||||
let workspace_ids = resolve_export_workspace_ids(ctx, args.workspace_ids, args.all)?;
|
||||
let workspace_id_refs: Vec<&str> = workspace_ids.iter().map(String::as_str).collect();
|
||||
export::export_data(ExportDataParams {
|
||||
query_manager: ctx.query_manager(),
|
||||
yaak_version: env!("CARGO_PKG_VERSION"),
|
||||
export_path: &args.file,
|
||||
workspace_ids: workspace_id_refs,
|
||||
include_private_environments: args.include_private_environments,
|
||||
})
|
||||
.map_err(|e| format!("Failed to export data: {e}"))?;
|
||||
|
||||
Ok(workspace_ids.len())
|
||||
}
|
||||
|
||||
fn resolve_export_workspace_ids(
|
||||
ctx: &CliContext,
|
||||
workspace_ids: Vec<String>,
|
||||
all: bool,
|
||||
) -> CommandResult<Vec<String>> {
|
||||
if all {
|
||||
let workspaces =
|
||||
ctx.db().list_workspaces().map_err(|e| format!("Failed to list workspaces: {e}"))?;
|
||||
if workspaces.is_empty() {
|
||||
return Err("No workspaces found to export".to_string());
|
||||
}
|
||||
return Ok(workspaces.into_iter().map(|w| w.id).collect());
|
||||
}
|
||||
|
||||
if workspace_ids.is_empty() {
|
||||
return resolve_workspace_id(ctx, None, "export").map(|id| vec![id]);
|
||||
}
|
||||
|
||||
for workspace_id in &workspace_ids {
|
||||
ctx.db()
|
||||
.get_workspace(workspace_id)
|
||||
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
|
||||
}
|
||||
Ok(workspace_ids)
|
||||
}
|
||||
|
||||
fn read_import_file(path: &std::path::Path) -> CommandResult<String> {
|
||||
fs::read_to_string(path).map_err(|err| {
|
||||
if err.kind() == ErrorKind::InvalidData {
|
||||
format!(
|
||||
"Import file must be UTF-8 text; binary files are not supported: {}",
|
||||
path.display()
|
||||
)
|
||||
} else {
|
||||
format!("Unable to read import file {}: {err}", path.display())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn resources_need_current_workspace(resources: &ImportResources) -> bool {
|
||||
resources.workspaces.iter().any(|w| w.id == "CURRENT_WORKSPACE")
|
||||
|| resources.environments.iter().any(|e| {
|
||||
e.workspace_id == "CURRENT_WORKSPACE"
|
||||
|| e.parent_id.as_deref() == Some("CURRENT_WORKSPACE")
|
||||
})
|
||||
|| resources.folders.iter().any(|f| {
|
||||
f.workspace_id == "CURRENT_WORKSPACE"
|
||||
|| f.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
|
||||
})
|
||||
|| resources.http_requests.iter().any(|r| {
|
||||
r.workspace_id == "CURRENT_WORKSPACE"
|
||||
|| r.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
|
||||
})
|
||||
|| resources.grpc_requests.iter().any(|r| {
|
||||
r.workspace_id == "CURRENT_WORKSPACE"
|
||||
|| r.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
|
||||
})
|
||||
|| resources.websocket_requests.iter().any(|r| {
|
||||
r.workspace_id == "CURRENT_WORKSPACE"
|
||||
|| r.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
|
||||
})
|
||||
}
|
||||
|
||||
fn format_counts(result: &BatchUpsertResult) -> String {
|
||||
let names = [
|
||||
"workspace",
|
||||
"environment",
|
||||
"folder",
|
||||
"HTTP request",
|
||||
"gRPC request",
|
||||
"WebSocket request",
|
||||
];
|
||||
let counts = [
|
||||
(result.workspaces.len(), names[0]),
|
||||
(result.environments.len(), names[1]),
|
||||
(result.folders.len(), names[2]),
|
||||
(result.http_requests.len(), names[3]),
|
||||
(result.grpc_requests.len(), names[4]),
|
||||
(result.websocket_requests.len(), names[5]),
|
||||
];
|
||||
|
||||
let non_zero: Vec<String> = counts
|
||||
.into_iter()
|
||||
.filter(|(count, _)| *count > 0)
|
||||
.map(|(count, name)| format!("{count} {name}{}", if count == 1 { "" } else { "s" }))
|
||||
.collect();
|
||||
|
||||
if non_zero.is_empty() { "nothing".to_string() } else { non_zero.join(", ") }
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod auth;
|
||||
pub mod cookie_jar;
|
||||
pub mod environment;
|
||||
pub mod folder;
|
||||
pub mod import_export;
|
||||
pub mod plugin;
|
||||
pub mod request;
|
||||
pub mod send;
|
||||
|
||||
@@ -13,6 +13,7 @@ use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::{self, IsTerminal, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use walkdir::WalkDir;
|
||||
@@ -27,6 +28,11 @@ use zip::write::SimpleFileOptions;
|
||||
type CommandResult<T = ()> = std::result::Result<T, String>;
|
||||
|
||||
const KEYRING_USER: &str = "yaak";
|
||||
const METADATA_NODE_BIN: &str = "node";
|
||||
const PLUGIN_RUNTIME_NODE_VERSION: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../packages/plugin-runtime/.node-version"
|
||||
));
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum Environment {
|
||||
@@ -103,6 +109,16 @@ pub async fn run_publish(args: PluginPathArg) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_metadata(args: PluginPathArg) -> i32 {
|
||||
match metadata(args) {
|
||||
Ok(()) => 0,
|
||||
Err(error) => {
|
||||
ui::error(&error);
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn build(args: PluginPathArg) -> CommandResult {
|
||||
let plugin_dir = resolve_plugin_dir(args.path)?;
|
||||
ensure_plugin_build_inputs(&plugin_dir)?;
|
||||
@@ -112,10 +128,21 @@ async fn build(args: PluginPathArg) -> CommandResult {
|
||||
for warning in warnings {
|
||||
ui::warning(&warning);
|
||||
}
|
||||
generate_plugin_metadata(&plugin_dir)?;
|
||||
ui::success(&format!("Built plugin bundle at {}", plugin_dir.join("build/index.js").display()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn metadata(args: PluginPathArg) -> CommandResult {
|
||||
let plugin_dir = resolve_plugin_dir(args.path)?;
|
||||
generate_plugin_metadata(&plugin_dir)?;
|
||||
ui::success(&format!(
|
||||
"Generated plugin metadata at {}",
|
||||
plugin_dir.join("build/metadata.json").display()
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn dev(args: PluginPathArg) -> CommandResult {
|
||||
let plugin_dir = resolve_plugin_dir(args.path)?;
|
||||
ensure_plugin_build_inputs(&plugin_dir)?;
|
||||
@@ -153,7 +180,15 @@ async fn dev(args: PluginPathArg) -> CommandResult {
|
||||
});
|
||||
ui::info(&format!("Rebuilding plugin {display_path}"));
|
||||
}
|
||||
WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {}
|
||||
WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {
|
||||
match generate_plugin_metadata(&watch_root) {
|
||||
Ok(()) => ui::success(&format!(
|
||||
"Generated plugin metadata at {}",
|
||||
watch_root.join("build/metadata.json").display()
|
||||
)),
|
||||
Err(error) => ui::error(&error),
|
||||
}
|
||||
}
|
||||
WatcherEvent::Event(BundleEvent::Error(event)) => {
|
||||
if event.error.diagnostics.is_empty() {
|
||||
ui::error("Plugin build failed");
|
||||
@@ -228,6 +263,7 @@ async fn publish(args: PluginPathArg) -> CommandResult {
|
||||
for warning in warnings {
|
||||
ui::warning(&warning);
|
||||
}
|
||||
generate_plugin_metadata(&plugin_dir)?;
|
||||
|
||||
ui::info("Archiving plugin");
|
||||
let archive = create_publish_archive(&plugin_dir)?;
|
||||
@@ -379,6 +415,79 @@ async fn build_plugin_bundle(plugin_dir: &Path) -> CommandResult<Vec<String>> {
|
||||
Ok(output.warnings.into_iter().map(|w| w.to_string()).collect())
|
||||
}
|
||||
|
||||
fn generate_plugin_metadata(plugin_dir: &Path) -> CommandResult {
|
||||
let entry_path = plugin_dir.join("build/index.js");
|
||||
if !entry_path.is_file() {
|
||||
return Err("build/index.js does not exist. Run `yaak plugin build` first.".to_string());
|
||||
}
|
||||
|
||||
ensure_metadata_node_version()?;
|
||||
|
||||
let metadata_path = plugin_dir.join("build/metadata.json");
|
||||
let output = Command::new(METADATA_NODE_BIN)
|
||||
.arg("-e")
|
||||
.arg(METADATA_SCRIPT)
|
||||
.arg(entry_path.canonicalize().map_err(|e| {
|
||||
format!("Failed to resolve plugin entrypoint {}: {e}", entry_path.display())
|
||||
})?)
|
||||
.arg(&metadata_path)
|
||||
.current_dir(plugin_dir)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run Node.js to generate plugin metadata: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let message = if stderr.is_empty() {
|
||||
format!("Node.js exited with status {}", output.status)
|
||||
} else {
|
||||
stderr
|
||||
};
|
||||
return Err(format!("Failed to generate plugin metadata: {message}"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_metadata_node_version() -> CommandResult {
|
||||
let minimum_major = PLUGIN_RUNTIME_NODE_VERSION
|
||||
.trim()
|
||||
.trim_start_matches('v')
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|part| part.parse::<u32>().ok())
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Invalid plugin runtime Node.js version {:?} in packages/plugin-runtime/.node-version",
|
||||
PLUGIN_RUNTIME_NODE_VERSION.trim()
|
||||
)
|
||||
})?;
|
||||
let output = Command::new(METADATA_NODE_BIN)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map_err(|e| format!("Node.js {minimum_major} or newer is required: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"`{METADATA_NODE_BIN} --version` failed with status {}",
|
||||
output.status
|
||||
));
|
||||
}
|
||||
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let major = version
|
||||
.trim_start_matches('v')
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|part| part.parse::<u32>().ok())
|
||||
.ok_or_else(|| format!("Could not parse Node.js version {version:?}"))?;
|
||||
|
||||
if major >= minimum_major {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!("Node.js {minimum_major} or newer is required. Found {version}."))
|
||||
}
|
||||
|
||||
fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult {
|
||||
let build_dir = plugin_dir.join("build");
|
||||
if build_dir.exists() {
|
||||
@@ -578,6 +687,11 @@ const TEMPLATE_PACKAGE_JSON: &str = r#"{
|
||||
}
|
||||
"#;
|
||||
|
||||
const METADATA_SCRIPT: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../packages/plugin-runtime/src/metadata.ts"
|
||||
));
|
||||
|
||||
const TEMPLATE_TSCONFIG: &str = r#"{
|
||||
"compilerOptions": {
|
||||
"target": "es2021",
|
||||
@@ -636,7 +750,8 @@ describe("Example Plugin", () => {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::create_publish_archive;
|
||||
use super::{create_publish_archive, generate_plugin_metadata};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::Cursor;
|
||||
@@ -659,6 +774,7 @@ mod tests {
|
||||
.expect("write src/index.ts");
|
||||
fs::write(root.join("build/index.js"), "exports.plugin = {};\n")
|
||||
.expect("write build/index.js");
|
||||
fs::write(root.join("build/metadata.json"), "{}\n").expect("write build/metadata.json");
|
||||
fs::write(root.join("ignored/secret.txt"), "do-not-ship").expect("write ignored file");
|
||||
|
||||
let archive = create_publish_archive(root).expect("create archive");
|
||||
@@ -673,8 +789,74 @@ mod tests {
|
||||
assert!(names.contains("README.md"));
|
||||
assert!(names.contains("package.json"));
|
||||
assert!(names.contains("package-lock.json"));
|
||||
assert!(names.contains("build/metadata.json"));
|
||||
assert!(names.contains("src/index.ts"));
|
||||
assert!(names.contains("build/index.js"));
|
||||
assert!(!names.contains("ignored/secret.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_plugin_metadata_detects_api_types() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::write(
|
||||
root.join("build/index.js"),
|
||||
r##"
|
||||
exports.plugin = {
|
||||
themes: [{
|
||||
id: "midnight",
|
||||
label: "Midnight",
|
||||
dark: true,
|
||||
base: { surface: "#000000", text: "#ffffff" },
|
||||
}],
|
||||
templateFunctions: [{
|
||||
name: "signature",
|
||||
description: "Create a signature",
|
||||
args: [{ type: "text", name: "secret", dynamic() {} }],
|
||||
onRender() {},
|
||||
}],
|
||||
workspaceActions: [{
|
||||
label: "Sync workspace",
|
||||
icon: "info",
|
||||
onSelect() {},
|
||||
}],
|
||||
folderActions: [{
|
||||
label: "Export folder",
|
||||
icon: "copy",
|
||||
onSelect() {},
|
||||
}],
|
||||
async init() {},
|
||||
};
|
||||
"##,
|
||||
)
|
||||
.expect("write build/index.js");
|
||||
|
||||
generate_plugin_metadata(root).expect("generate metadata");
|
||||
|
||||
let contents = fs::read_to_string(root.join("build/metadata.json")).expect("read metadata");
|
||||
let metadata: Value = serde_json::from_str(&contents).expect("metadata json");
|
||||
let api_types = metadata["apiTypes"].as_array().expect("apiTypes array");
|
||||
|
||||
for expected in [
|
||||
"folderActions",
|
||||
"templateFunctions",
|
||||
"themes",
|
||||
"workspaceActions",
|
||||
"lifecycle",
|
||||
] {
|
||||
assert!(
|
||||
api_types.iter().any(|value| value.as_str() == Some(expected)),
|
||||
"missing api type {expected}: {api_types:?}"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(metadata["apis"]["themes"]["items"][0]["id"], "midnight");
|
||||
assert_eq!(metadata["apis"]["workspaceActions"]["items"][0]["label"], "Sync workspace");
|
||||
assert_eq!(metadata["apis"]["lifecycle"]["items"][0]["name"], "init");
|
||||
assert!(metadata["apis"]["templateFunctions"]["items"][0]["onRender"].is_null());
|
||||
assert!(
|
||||
metadata["apis"]["templateFunctions"]["items"][0]["args"][0]["dynamic"].is_null()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,11 +37,29 @@ async fn main() {
|
||||
|
||||
let exit_code = match command {
|
||||
Commands::Auth(args) => commands::auth::run(args).await,
|
||||
Commands::Import(args) => {
|
||||
let mut context = CliContext::new(data_dir.clone(), app_id);
|
||||
let execution_context = CliExecutionContext {
|
||||
workspace_id: args.workspace_id.clone(),
|
||||
..CliExecutionContext::default()
|
||||
};
|
||||
context.init_plugins(execution_context).await;
|
||||
let exit_code = commands::import_export::run_import(&context, args).await;
|
||||
context.shutdown().await;
|
||||
exit_code
|
||||
}
|
||||
Commands::Export(args) => {
|
||||
let context = CliContext::new(data_dir.clone(), app_id);
|
||||
let exit_code = commands::import_export::run_export(&context, args);
|
||||
context.shutdown().await;
|
||||
exit_code
|
||||
}
|
||||
Commands::Plugin(args) => match args.command {
|
||||
PluginCommands::Build(args) => commands::plugin::run_build(args).await,
|
||||
PluginCommands::Dev(args) => commands::plugin::run_dev(args).await,
|
||||
PluginCommands::Generate(args) => commands::plugin::run_generate(args).await,
|
||||
PluginCommands::Publish(args) => commands::plugin::run_publish(args).await,
|
||||
PluginCommands::Metadata(args) => commands::plugin::run_metadata(args).await,
|
||||
PluginCommands::Install(install_args) => {
|
||||
let mut context = CliContext::new(data_dir.clone(), app_id);
|
||||
context.init_plugins(CliExecutionContext::default()).await;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
mod common;
|
||||
|
||||
use common::{cli_cmd, parse_created_id, query_manager, seed_request};
|
||||
use predicates::str::contains;
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn export_writes_yaak_workspace_file() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path();
|
||||
let export_path = temp_dir.path().join("export.json");
|
||||
|
||||
let create_assert =
|
||||
cli_cmd(data_dir).args(["workspace", "create", "--name", "Export Me"]).assert().success();
|
||||
let workspace_id = parse_created_id(&create_assert.get_output().stdout, "workspace create");
|
||||
seed_request(data_dir, &workspace_id, "req_export");
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args([
|
||||
"export",
|
||||
export_path.to_str().expect("export path is utf-8"),
|
||||
&workspace_id,
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Exported 1 workspace(s)"));
|
||||
|
||||
let exported: Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(export_path).expect("export file should exist"),
|
||||
)
|
||||
.expect("export should be JSON");
|
||||
|
||||
assert_eq!(exported["yaakSchema"], 4);
|
||||
assert_eq!(exported["resources"]["workspaces"][0]["id"], workspace_id);
|
||||
assert_eq!(exported["resources"]["httpRequests"][0]["id"], "req_export");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_reads_yaak_workspace_file() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path();
|
||||
let import_path = temp_dir.path().join("import.json");
|
||||
|
||||
std::fs::write(
|
||||
&import_path,
|
||||
r#"{
|
||||
"yaakVersion": "test",
|
||||
"yaakSchema": 4,
|
||||
"resources": {
|
||||
"workspaces": [
|
||||
{
|
||||
"model": "workspace",
|
||||
"id": "wrk_import",
|
||||
"name": "Imported Workspace"
|
||||
}
|
||||
],
|
||||
"httpRequests": [
|
||||
{
|
||||
"model": "http_request",
|
||||
"id": "req_import",
|
||||
"workspaceId": "wrk_import",
|
||||
"name": "Imported Request",
|
||||
"method": "GET",
|
||||
"url": "https://example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write import fixture");
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args([
|
||||
"import",
|
||||
import_path.to_str().expect("import path is utf-8"),
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Imported 1 workspace, 1 HTTP request"));
|
||||
|
||||
let query_manager = query_manager(data_dir);
|
||||
let db = query_manager.connect();
|
||||
assert_eq!(
|
||||
db.get_workspace("wrk_import").expect("workspace imported").name,
|
||||
"Imported Workspace"
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_http_request("req_import").expect("request imported").url,
|
||||
"https://example.com"
|
||||
);
|
||||
}
|
||||
|
||||
fn write_postman_environment_fixture(path: &std::path::Path) {
|
||||
std::fs::write(
|
||||
path,
|
||||
r#"{
|
||||
"name": "Local",
|
||||
"_postman_variable_scope": "environment",
|
||||
"values": [
|
||||
{
|
||||
"key": "token",
|
||||
"value": "abc123",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("write postman environment fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_postman_environment_requires_workspace_id() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path();
|
||||
let import_path = temp_dir.path().join("postman-env.json");
|
||||
|
||||
cli_cmd(data_dir).args(["workspace", "create", "--name", "Env Target"]).assert().success();
|
||||
write_postman_environment_fixture(&import_path);
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args([
|
||||
"import",
|
||||
import_path.to_str().expect("import path is utf-8"),
|
||||
])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains("requires a workspace context"))
|
||||
.stderr(contains("--workspace-id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_postman_environment_uses_workspace_id() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path();
|
||||
let import_path = temp_dir.path().join("postman-env.json");
|
||||
|
||||
let create_assert =
|
||||
cli_cmd(data_dir).args(["workspace", "create", "--name", "Env Target"]).assert().success();
|
||||
let workspace_id = parse_created_id(&create_assert.get_output().stdout, "workspace create");
|
||||
write_postman_environment_fixture(&import_path);
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args([
|
||||
"import",
|
||||
import_path.to_str().expect("import path is utf-8"),
|
||||
"--workspace-id",
|
||||
&workspace_id,
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Imported 1 environment"));
|
||||
|
||||
let query_manager = query_manager(data_dir);
|
||||
let db = query_manager.connect();
|
||||
let environments =
|
||||
db.list_environments_ensure_base(&workspace_id).expect("list imported environments");
|
||||
|
||||
let imported_environment =
|
||||
environments.iter().find(|e| e.name == "Local").expect("postman environment imported");
|
||||
assert_eq!(imported_environment.workspace_id, workspace_id);
|
||||
}
|
||||
@@ -38,6 +38,9 @@ pub enum Error {
|
||||
#[error(transparent)]
|
||||
ApiError(#[from] yaak_api::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
YaakError(#[from] yaak::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
ClipboardError(#[from] tauri_plugin_clipboard_manager::Error),
|
||||
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use log::info;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::read_to_string;
|
||||
use std::io::ErrorKind;
|
||||
use tauri::{Manager, Runtime, WebviewWindow};
|
||||
use yaak::import::{self, ImportDataParams};
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_models::models::{
|
||||
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
|
||||
};
|
||||
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
|
||||
@@ -19,113 +15,24 @@ pub(crate) async fn import_data<R: Runtime>(
|
||||
file_path: &str,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
let plugin_manager = window.state::<PluginManager>();
|
||||
let query_manager = window.db_manager();
|
||||
let file = read_import_file(file_path)?;
|
||||
let file_contents = file.as_str();
|
||||
let import_result = plugin_manager.import_data(&window.plugin_context(), file_contents).await?;
|
||||
|
||||
let mut id_map: BTreeMap<String, String> = BTreeMap::new();
|
||||
|
||||
// Create WorkspaceContext from window
|
||||
let ctx = WorkspaceContext {
|
||||
let plugin_context = window.plugin_context();
|
||||
let workspace_context = WorkspaceContext {
|
||||
workspace_id: window.workspace_id(),
|
||||
environment_id: window.environment_id(),
|
||||
cookie_jar_id: window.cookie_jar_id(),
|
||||
request_id: None,
|
||||
};
|
||||
|
||||
let resources = import_result.resources;
|
||||
|
||||
let workspaces: Vec<Workspace> = resources
|
||||
.workspaces
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<Workspace>(&ctx, v.id.as_str(), &mut id_map);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
let environments: Vec<Environment> = resources
|
||||
.environments
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<Environment>(&ctx, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
|
||||
match (v.parent_model.as_str(), v.parent_id.clone().as_deref()) {
|
||||
("folder", Some(parent_id)) => {
|
||||
v.parent_id = Some(maybe_gen_id::<Folder>(&ctx, &parent_id, &mut id_map));
|
||||
}
|
||||
("", _) => {
|
||||
// Fix any empty ones
|
||||
v.parent_model = "workspace".to_string();
|
||||
}
|
||||
_ => {
|
||||
// Parent ID only required for the folder case
|
||||
v.parent_id = None;
|
||||
}
|
||||
};
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
let folders: Vec<Folder> = resources
|
||||
.folders
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<Folder>(&ctx, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
|
||||
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
let http_requests: Vec<HttpRequest> = resources
|
||||
.http_requests
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<HttpRequest>(&ctx, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
|
||||
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
let grpc_requests: Vec<GrpcRequest> = resources
|
||||
.grpc_requests
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<GrpcRequest>(&ctx, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
|
||||
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
let websocket_requests: Vec<WebsocketRequest> = resources
|
||||
.websocket_requests
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<WebsocketRequest>(&ctx, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
|
||||
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
info!("Importing data");
|
||||
|
||||
let upserted = window.with_tx(|tx| {
|
||||
tx.batch_upsert(
|
||||
workspaces,
|
||||
environments,
|
||||
folders,
|
||||
http_requests,
|
||||
grpc_requests,
|
||||
websocket_requests,
|
||||
&UpdateSource::Import,
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(upserted)
|
||||
Ok(import::import_data(ImportDataParams {
|
||||
query_manager: &query_manager,
|
||||
plugin_manager: &plugin_manager,
|
||||
plugin_context: &plugin_context,
|
||||
workspace_context,
|
||||
contents: &file,
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
|
||||
fn read_import_file(file_path: &str) -> Result<String> {
|
||||
|
||||
@@ -14,8 +14,7 @@ use error::Result as YaakResult;
|
||||
use eventsource_client::{EventParser, SSE};
|
||||
use log::{debug, error, info, warn};
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -31,6 +30,7 @@ use tauri_plugin_window_state::{AppHandleExt, StateFlags};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::block_in_place;
|
||||
use tokio::time;
|
||||
use yaak::export::{self, ExportDataParams};
|
||||
use yaak_common::command::new_checked_command;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
|
||||
@@ -41,7 +41,7 @@ use yaak_models::models::{
|
||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace,
|
||||
WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};
|
||||
use yaak_models::util::{BatchUpsertResult, UpdateSource};
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
|
||||
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
|
||||
@@ -54,7 +54,7 @@ use yaak_plugins::events::{
|
||||
InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest,
|
||||
};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::plugin_meta::PluginMetadata;
|
||||
use yaak_plugins::plugin_meta::{PluginMetadata, get_plugin_meta};
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_sse::sse::ServerSentEvent;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
@@ -295,7 +295,8 @@ async fn cmd_grpc_reflect<R: Runtime>(
|
||||
unrendered_request.folder_id.as_deref(),
|
||||
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 encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
@@ -332,6 +333,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
|
||||
&metadata,
|
||||
resolved_settings.validate_certificates.value,
|
||||
client_certificate,
|
||||
resolved_settings.request_message_size.value,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| GenericError(e.to_string()))?)
|
||||
@@ -353,7 +355,8 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
unrendered_request.folder_id.as_deref(),
|
||||
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 encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
@@ -425,6 +428,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
&metadata,
|
||||
resolved_settings.validate_certificates.value,
|
||||
client_cert.clone(),
|
||||
resolved_settings.request_message_size.value,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1384,24 +1388,14 @@ async fn cmd_export_data<R: Runtime>(
|
||||
workspace_ids: Vec<&str>,
|
||||
include_private_environments: bool,
|
||||
) -> YaakResult<()> {
|
||||
let db = app_handle.db();
|
||||
let version = app_handle.package_info().version.to_string();
|
||||
let export_data =
|
||||
get_workspace_export_resources(&db, &version, workspace_ids, include_private_environments)?;
|
||||
let f = File::options()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(export_path)
|
||||
.expect("Unable to create file");
|
||||
|
||||
serde_json::to_writer_pretty(&f, &export_data)
|
||||
.map_err(|e| GenericError(e.to_string()))
|
||||
.expect("Failed to write");
|
||||
|
||||
f.sync_all().expect("Failed to sync");
|
||||
|
||||
Ok(())
|
||||
Ok(export::export_data(ExportDataParams {
|
||||
query_manager: &app_handle.db_manager(),
|
||||
yaak_version: &version,
|
||||
export_path: Path::new(export_path),
|
||||
workspace_ids,
|
||||
include_private_environments,
|
||||
})?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1425,11 +1419,10 @@ async fn cmd_send_http_request<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
environment_id: Option<&str>,
|
||||
cookie_jar_id: Option<&str>,
|
||||
// NOTE: We receive the entire request because to account for the race
|
||||
// condition where the user may have just edited a field before sending
|
||||
// that has not yet been saved in the DB.
|
||||
request: HttpRequest,
|
||||
request_id: String,
|
||||
) -> YaakResult<HttpResponse> {
|
||||
let request = app_handle.db().get_http_request(&request_id)?;
|
||||
|
||||
let blobs = app_handle.blob_manager();
|
||||
let response = app_handle.db().upsert_http_response(
|
||||
&HttpResponse {
|
||||
@@ -1512,11 +1505,36 @@ async fn cmd_plugin_info<R: Runtime>(
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<PluginMetadata> {
|
||||
let plugin = app_handle.db().get_plugin(id)?;
|
||||
Ok(plugin_manager
|
||||
if let Some(plugin_handle) = plugin_manager
|
||||
.get_plugin_by_dir(plugin.directory.as_str())
|
||||
.await
|
||||
.ok_or(GenericError("Failed to find plugin for info".to_string()))?
|
||||
.info())
|
||||
{
|
||||
return Ok(plugin_handle.info());
|
||||
}
|
||||
|
||||
if let Ok(metadata) = get_plugin_meta(&PathBuf::from(&plugin.directory)) {
|
||||
return Ok(metadata);
|
||||
}
|
||||
|
||||
Ok(fallback_plugin_metadata(&plugin.directory))
|
||||
}
|
||||
|
||||
fn fallback_plugin_metadata(directory: &str) -> PluginMetadata {
|
||||
let display_name = PathBuf::from(directory)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or(directory)
|
||||
.to_string();
|
||||
|
||||
PluginMetadata {
|
||||
version: "Unavailable".to_string(),
|
||||
name: directory.to_string(),
|
||||
display_name,
|
||||
description: Some(format!("Plugin metadata could not be loaded from {directory}")),
|
||||
homepage_url: None,
|
||||
repository_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -299,6 +299,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
receive_tx,
|
||||
resolved_settings.validate_certificates.value,
|
||||
client_cert,
|
||||
resolved_settings.request_message_size.value,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
Generated
+4
@@ -46,6 +46,7 @@ export type Folder = {
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type GrpcRequest = {
|
||||
@@ -69,6 +70,7 @@ export type GrpcRequest = {
|
||||
*/
|
||||
url: string;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type HttpRequest = {
|
||||
@@ -146,6 +148,7 @@ export type WebsocketRequest = {
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type Workspace = {
|
||||
@@ -162,6 +165,7 @@ export type Workspace = {
|
||||
settingValidateCertificates: boolean;
|
||||
settingFollowRedirects: boolean;
|
||||
settingRequestTimeout: number;
|
||||
settingRequestMessageSize: number;
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
|
||||
@@ -33,15 +33,21 @@ impl AutoReflectionClient {
|
||||
uri: &Uri,
|
||||
validate_certificates: bool,
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
max_message_size: usize,
|
||||
) -> Result<Self> {
|
||||
let client_v1 = v1::server_reflection_client::ServerReflectionClient::with_origin(
|
||||
get_transport(validate_certificates, client_cert.clone())?,
|
||||
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);
|
||||
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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ pub struct GrpcConnection {
|
||||
conn: Client<HttpsConnector<HttpConnector>, BoxBody>,
|
||||
pub uri: Uri,
|
||||
use_reflection: bool,
|
||||
max_message_size: usize,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
@@ -97,8 +98,15 @@ impl GrpcConnection {
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
) -> Result<Response<DynamicMessage>> {
|
||||
if self.use_reflection {
|
||||
reflect_types_for_message(self.pool.clone(), &self.uri, message, metadata, client_cert)
|
||||
.await?;
|
||||
reflect_types_for_message(
|
||||
self.pool.clone(),
|
||||
&self.uri,
|
||||
message,
|
||||
metadata,
|
||||
client_cert,
|
||||
self.max_message_size,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let method = &self.method(&service, &method).await?;
|
||||
let input_message = method.input();
|
||||
@@ -107,7 +115,7 @@ impl GrpcConnection {
|
||||
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
|
||||
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();
|
||||
decorate_req(metadata, &mut req)?;
|
||||
@@ -132,6 +140,7 @@ impl GrpcConnection {
|
||||
message,
|
||||
metadata,
|
||||
client_cert,
|
||||
self.max_message_size,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -171,6 +180,7 @@ impl GrpcConnection {
|
||||
let md = metadata.clone();
|
||||
let use_reflection = self.use_reflection.clone();
|
||||
let client_cert = client_cert.clone();
|
||||
let max_message_size = self.max_message_size;
|
||||
stream
|
||||
.then(move |json| {
|
||||
let pool = pool.clone();
|
||||
@@ -183,8 +193,15 @@ impl GrpcConnection {
|
||||
let json_clone = json.clone();
|
||||
async move {
|
||||
if use_reflection {
|
||||
if let Err(e) =
|
||||
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
|
||||
if let Err(e) = reflect_types_for_message(
|
||||
pool,
|
||||
&uri,
|
||||
&json,
|
||||
&md,
|
||||
client_cert,
|
||||
max_message_size,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to resolve Any types: {e}");
|
||||
}
|
||||
@@ -206,7 +223,7 @@ impl GrpcConnection {
|
||||
.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 codec = DynamicCodec::new(method.clone());
|
||||
|
||||
@@ -237,6 +254,7 @@ impl GrpcConnection {
|
||||
let md = metadata.clone();
|
||||
let use_reflection = self.use_reflection.clone();
|
||||
let client_cert = client_cert.clone();
|
||||
let max_message_size = self.max_message_size;
|
||||
stream
|
||||
.then(move |json| {
|
||||
let pool = pool.clone();
|
||||
@@ -249,8 +267,15 @@ impl GrpcConnection {
|
||||
let json_clone = json.clone();
|
||||
async move {
|
||||
if use_reflection {
|
||||
if let Err(e) =
|
||||
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
|
||||
if let Err(e) = reflect_types_for_message(
|
||||
pool,
|
||||
&uri,
|
||||
&json,
|
||||
&md,
|
||||
client_cert,
|
||||
max_message_size,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to resolve Any types: {e}");
|
||||
}
|
||||
@@ -272,7 +297,7 @@ impl GrpcConnection {
|
||||
.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 codec = DynamicCodec::new(method.clone());
|
||||
|
||||
@@ -300,7 +325,7 @@ impl GrpcConnection {
|
||||
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
|
||||
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();
|
||||
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
|
||||
#[derive(Clone)]
|
||||
pub struct GrpcConfig {
|
||||
@@ -348,6 +390,7 @@ impl GrpcHandle {
|
||||
metadata: &BTreeMap<String, String>,
|
||||
validate_certificates: bool,
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
request_message_size: i32,
|
||||
) -> Result<bool> {
|
||||
let server_reflection = proto_files.is_empty();
|
||||
let key = make_pool_key(id, uri, proto_files);
|
||||
@@ -359,7 +402,14 @@ impl GrpcHandle {
|
||||
|
||||
let pool = if server_reflection {
|
||||
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 {
|
||||
fill_pool_from_files(&self.config, proto_files).await
|
||||
}?;
|
||||
@@ -376,12 +426,21 @@ impl GrpcHandle {
|
||||
metadata: &BTreeMap<String, String>,
|
||||
validate_certificates: bool,
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
request_message_size: i32,
|
||||
) -> Result<Vec<ServiceDefinition>> {
|
||||
// Ensure we have a pool; reflect only if missing
|
||||
if self.get_pool(id, uri, proto_files).is_none() {
|
||||
info!("Reflecting gRPC services for {} at {}", id, uri);
|
||||
self.reflect(id, uri, proto_files, metadata, validate_certificates, client_cert)
|
||||
.await?;
|
||||
self.reflect(
|
||||
id,
|
||||
uri,
|
||||
proto_files,
|
||||
metadata,
|
||||
validate_certificates,
|
||||
client_cert,
|
||||
request_message_size,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let pool = self
|
||||
@@ -421,8 +480,10 @@ impl GrpcHandle {
|
||||
metadata: &BTreeMap<String, String>,
|
||||
validate_certificates: bool,
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
request_message_size: i32,
|
||||
) -> Result<GrpcConnection> {
|
||||
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() {
|
||||
self.reflect(
|
||||
id,
|
||||
@@ -431,6 +492,7 @@ impl GrpcHandle {
|
||||
metadata,
|
||||
validate_certificates,
|
||||
client_cert.clone(),
|
||||
request_message_size,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -440,7 +502,13 @@ impl GrpcHandle {
|
||||
.clone();
|
||||
let uri = uri_from_str(uri)?;
|
||||
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> {
|
||||
|
||||
@@ -119,9 +119,11 @@ pub async fn fill_pool_from_reflection(
|
||||
metadata: &BTreeMap<String, String>,
|
||||
validate_certificates: bool,
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
max_message_size: usize,
|
||||
) -> Result<DescriptorPool> {
|
||||
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? {
|
||||
if service == "grpc.reflection.v1alpha.ServerReflection" {
|
||||
@@ -192,6 +194,7 @@ pub(crate) async fn reflect_types_for_message(
|
||||
json: &str,
|
||||
metadata: &BTreeMap<String, String>,
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
max_message_size: usize,
|
||||
) -> Result<()> {
|
||||
// 1. Collect all Any types in the JSON
|
||||
let mut extra_types = Vec::new();
|
||||
@@ -201,7 +204,7 @@ pub(crate) async fn reflect_types_for_message(
|
||||
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 {
|
||||
{
|
||||
let guard = pool.read().await;
|
||||
@@ -239,6 +242,7 @@ pub(crate) async fn reflect_types_for_dynamic_message(
|
||||
message: &DynamicMessage,
|
||||
metadata: &BTreeMap<String, String>,
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
max_message_size: usize,
|
||||
) -> Result<()> {
|
||||
let mut extra_types = HashSet::new();
|
||||
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(());
|
||||
}
|
||||
|
||||
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 {
|
||||
{
|
||||
let guard = pool.read().await;
|
||||
|
||||
+4
@@ -109,6 +109,7 @@ export type Folder = {
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type GraphQlIntrospection = {
|
||||
@@ -184,6 +185,7 @@ export type GrpcRequest = {
|
||||
*/
|
||||
url: string;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type HttpRequest = {
|
||||
@@ -482,6 +484,7 @@ export type WebsocketRequest = {
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type Workspace = {
|
||||
@@ -498,6 +501,7 @@ export type Workspace = {
|
||||
settingValidateCertificates: boolean;
|
||||
settingFollowRedirects: boolean;
|
||||
settingRequestTimeout: number;
|
||||
settingRequestMessageSize: number;
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
|
||||
@@ -8,6 +8,8 @@ import { newStoreData } from "./util";
|
||||
|
||||
let _store: JotaiStore | null = null;
|
||||
|
||||
const pendingModelWrites = new Set<Promise<unknown>>();
|
||||
|
||||
export function initModelStore(store: JotaiStore) {
|
||||
_store = store;
|
||||
|
||||
@@ -42,6 +44,23 @@ function mustStore(): JotaiStore {
|
||||
return _store;
|
||||
}
|
||||
|
||||
function trackModelWrite<T>(write: Promise<T>): Promise<T> {
|
||||
const tracked = write.finally(() => {
|
||||
pendingModelWrites.delete(tracked);
|
||||
});
|
||||
|
||||
pendingModelWrites.add(tracked);
|
||||
return tracked;
|
||||
}
|
||||
|
||||
export async function flushAllModelWrites(): Promise<void> {
|
||||
const results = await Promise.allSettled([...pendingModelWrites]);
|
||||
const rejected = results.find((result) => result.status === "rejected");
|
||||
if (rejected?.status === "rejected") {
|
||||
throw rejected.reason;
|
||||
}
|
||||
}
|
||||
|
||||
let _activeWorkspaceId: string | null = null;
|
||||
|
||||
export async function changeModelStoreWorkspace(workspaceId: string | null) {
|
||||
@@ -117,7 +136,7 @@ export async function patchModel<M extends AnyModel["model"], T extends ExtractM
|
||||
export async function updateModel<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
|
||||
model: T,
|
||||
): Promise<string> {
|
||||
return invoke<string>("models_upsert", { model });
|
||||
return trackModelWrite(invoke<string>("models_upsert", { model }));
|
||||
}
|
||||
|
||||
export async function deleteModelById<
|
||||
@@ -134,7 +153,7 @@ export async function deleteModel<M extends AnyModel["model"], T extends Extract
|
||||
if (model == null) {
|
||||
throw new Error("Failed to delete null model");
|
||||
}
|
||||
await invoke<string>("models_delete", { model });
|
||||
await trackModelWrite(invoke<string>("models_delete", { model }));
|
||||
}
|
||||
|
||||
export function duplicateModel<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
|
||||
@@ -174,19 +193,19 @@ export function duplicateModel<M extends AnyModel["model"], T extends ExtractMod
|
||||
}
|
||||
}
|
||||
|
||||
return invoke<string>("models_duplicate", { model: { ...model, name } });
|
||||
return trackModelWrite(invoke<string>("models_duplicate", { model: { ...model, name } }));
|
||||
}
|
||||
|
||||
export async function createGlobalModel<T extends Exclude<AnyModel, { workspaceId: string }>>(
|
||||
patch: Partial<T> & Pick<T, "model">,
|
||||
): Promise<string> {
|
||||
return invoke<string>("models_upsert", { model: patch });
|
||||
return trackModelWrite(invoke<string>("models_upsert", { model: patch }));
|
||||
}
|
||||
|
||||
export async function createWorkspaceModel<T extends Extract<AnyModel, { workspaceId: string }>>(
|
||||
patch: Partial<T> & Pick<T, "model" | "workspaceId">,
|
||||
): Promise<string> {
|
||||
return invoke<string>("models_upsert", { model: patch });
|
||||
return trackModelWrite(invoke<string>("models_upsert", { model: patch }));
|
||||
}
|
||||
|
||||
export function replaceModelsInStore<
|
||||
|
||||
@@ -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;
|
||||
@@ -21,6 +21,8 @@ use ts_rs::TS;
|
||||
use yaak_database::{Result as DbResult, UpdateSource};
|
||||
pub use yaak_database::{UpsertModelInfo, upsert_date};
|
||||
|
||||
pub const DEFAULT_REQUEST_MESSAGE_SIZE: i32 = 64 * 1024 * 1024;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! impl_model {
|
||||
($t:ty, $variant:ident) => {
|
||||
@@ -120,6 +122,7 @@ pub struct ResolvedHttpRequestSettings {
|
||||
pub validate_certificates: ResolvedSetting<bool>,
|
||||
pub follow_redirects: ResolvedSetting<bool>,
|
||||
pub request_timeout: ResolvedSetting<i32>,
|
||||
pub request_message_size: ResolvedSetting<i32>,
|
||||
pub send_cookies: ResolvedSetting<bool>,
|
||||
pub store_cookies: ResolvedSetting<bool>,
|
||||
}
|
||||
@@ -130,6 +133,7 @@ impl Default for ResolvedHttpRequestSettings {
|
||||
validate_certificates: ResolvedSetting::default_source(true),
|
||||
follow_redirects: ResolvedSetting::default_source(true),
|
||||
request_timeout: ResolvedSetting::default_source(0),
|
||||
request_message_size: ResolvedSetting::default_source(DEFAULT_REQUEST_MESSAGE_SIZE),
|
||||
send_cookies: ResolvedSetting::default_source(true),
|
||||
store_cookies: ResolvedSetting::default_source(true),
|
||||
}
|
||||
@@ -400,6 +404,8 @@ pub struct Workspace {
|
||||
#[serde(default = "default_true")]
|
||||
pub setting_follow_redirects: bool,
|
||||
pub setting_request_timeout: i32,
|
||||
#[serde(default = "default_request_message_size")]
|
||||
pub setting_request_message_size: i32,
|
||||
#[serde(default)]
|
||||
pub setting_dns_overrides: Vec<DnsOverride>,
|
||||
#[serde(default = "default_true")]
|
||||
@@ -445,6 +451,7 @@ impl UpsertModelInfo for Workspace {
|
||||
(EncryptionKeyChallenge, self.encryption_key_challenge.into()),
|
||||
(SettingFollowRedirects, self.setting_follow_redirects.into()),
|
||||
(SettingRequestTimeout, self.setting_request_timeout.into()),
|
||||
(SettingRequestMessageSize, self.setting_request_message_size.into()),
|
||||
(SettingValidateCertificates, self.setting_validate_certificates.into()),
|
||||
(SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()),
|
||||
(SettingSendCookies, self.setting_send_cookies.into()),
|
||||
@@ -463,7 +470,7 @@ impl UpsertModelInfo for Workspace {
|
||||
WorkspaceIden::EncryptionKeyChallenge,
|
||||
WorkspaceIden::SettingRequestTimeout,
|
||||
WorkspaceIden::SettingFollowRedirects,
|
||||
WorkspaceIden::SettingRequestTimeout,
|
||||
WorkspaceIden::SettingRequestMessageSize,
|
||||
WorkspaceIden::SettingValidateCertificates,
|
||||
WorkspaceIden::SettingDnsOverrides,
|
||||
WorkspaceIden::SettingSendCookies,
|
||||
@@ -491,6 +498,7 @@ impl UpsertModelInfo for Workspace {
|
||||
authentication_type: row.get("authentication_type")?,
|
||||
setting_follow_redirects: row.get("setting_follow_redirects")?,
|
||||
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_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(),
|
||||
setting_send_cookies: row.get("setting_send_cookies")?,
|
||||
@@ -962,6 +970,8 @@ pub struct Folder {
|
||||
pub setting_validate_certificates: InheritedBoolSetting,
|
||||
pub setting_follow_redirects: InheritedBoolSetting,
|
||||
pub setting_request_timeout: InheritedIntSetting,
|
||||
#[serde(default = "default_request_message_size_setting")]
|
||||
pub setting_request_message_size: InheritedIntSetting,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for Folder {
|
||||
@@ -1009,6 +1019,10 @@ impl UpsertModelInfo for Folder {
|
||||
),
|
||||
(SettingFollowRedirects, serde_json::to_string(&self.setting_follow_redirects)?.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::SettingFollowRedirects,
|
||||
FolderIden::SettingRequestTimeout,
|
||||
FolderIden::SettingRequestMessageSize,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1041,6 +1056,7 @@ impl UpsertModelInfo for Folder {
|
||||
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
|
||||
let setting_follow_redirects: String = row.get("setting_follow_redirects")?;
|
||||
let setting_request_timeout: String = row.get("setting_request_timeout")?;
|
||||
let setting_request_message_size: String = row.get("setting_request_message_size")?;
|
||||
Ok(Self {
|
||||
id: row.get("id")?,
|
||||
model: row.get("model")?,
|
||||
@@ -1062,6 +1078,8 @@ impl UpsertModelInfo for Folder {
|
||||
.unwrap_or_default(),
|
||||
setting_request_timeout: serde_json::from_str(&setting_request_timeout)
|
||||
.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_store_cookies: InheritedBoolSetting,
|
||||
pub setting_validate_certificates: InheritedBoolSetting,
|
||||
#[serde(default = "default_request_message_size_setting")]
|
||||
pub setting_request_message_size: InheritedIntSetting,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for WebsocketRequest {
|
||||
@@ -1446,6 +1466,10 @@ impl UpsertModelInfo for WebsocketRequest {
|
||||
SettingValidateCertificates,
|
||||
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::SettingStoreCookies,
|
||||
WebsocketRequestIden::SettingValidateCertificates,
|
||||
WebsocketRequestIden::SettingRequestMessageSize,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1479,6 +1504,7 @@ impl UpsertModelInfo for WebsocketRequest {
|
||||
let setting_send_cookies: String = row.get("setting_send_cookies")?;
|
||||
let setting_store_cookies: String = row.get("setting_store_cookies")?;
|
||||
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
|
||||
let setting_request_message_size: String = row.get("setting_request_message_size")?;
|
||||
Ok(Self {
|
||||
id: row.get("id")?,
|
||||
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_validate_certificates: serde_json::from_str(&setting_validate_certificates)
|
||||
.unwrap_or_default(),
|
||||
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
|
||||
.unwrap_or_else(|_| default_request_message_size_setting()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2039,6 +2067,8 @@ pub struct GrpcRequest {
|
||||
/// Server URL (http for plaintext or https for secure)
|
||||
pub url: String,
|
||||
pub setting_validate_certificates: InheritedBoolSetting,
|
||||
#[serde(default = "default_request_message_size_setting")]
|
||||
pub setting_request_message_size: InheritedIntSetting,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for GrpcRequest {
|
||||
@@ -2086,6 +2116,10 @@ impl UpsertModelInfo for GrpcRequest {
|
||||
SettingValidateCertificates,
|
||||
serde_json::to_string(&self.setting_validate_certificates)?.into(),
|
||||
),
|
||||
(
|
||||
SettingRequestMessageSize,
|
||||
serde_json::to_string(&self.setting_request_message_size)?.into(),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -2105,6 +2139,7 @@ impl UpsertModelInfo for GrpcRequest {
|
||||
GrpcRequestIden::Authentication,
|
||||
GrpcRequestIden::Metadata,
|
||||
GrpcRequestIden::SettingValidateCertificates,
|
||||
GrpcRequestIden::SettingRequestMessageSize,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2115,6 +2150,7 @@ impl UpsertModelInfo for GrpcRequest {
|
||||
let authentication: String = row.get("authentication")?;
|
||||
let metadata: String = row.get("metadata")?;
|
||||
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
|
||||
let setting_request_message_size: String = row.get("setting_request_message_size")?;
|
||||
Ok(Self {
|
||||
id: row.get("id")?,
|
||||
model: row.get("model")?,
|
||||
@@ -2134,6 +2170,8 @@ impl UpsertModelInfo for GrpcRequest {
|
||||
metadata: serde_json::from_str(metadata.as_str()).unwrap_or_default(),
|
||||
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
|
||||
.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 +2722,14 @@ fn default_true() -> bool {
|
||||
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 {
|
||||
"GET".to_string()
|
||||
}
|
||||
|
||||
@@ -180,6 +180,14 @@ impl<'a> ClientDb<'a> {
|
||||
} else {
|
||||
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 {
|
||||
ResolvedSetting::from_model(
|
||||
folder.setting_send_cookies.value,
|
||||
|
||||
@@ -129,6 +129,14 @@ impl<'a> ClientDb<'a> {
|
||||
} else {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ impl<'a> ClientDb<'a> {
|
||||
} else {
|
||||
parent.request_timeout
|
||||
},
|
||||
request_message_size: parent.request_message_size,
|
||||
send_cookies: if http_request.setting_send_cookies.enabled {
|
||||
ResolvedSetting::from_model(
|
||||
http_request.setting_send_cookies.value,
|
||||
|
||||
@@ -139,6 +139,14 @@ impl<'a> ClientDb<'a> {
|
||||
} else {
|
||||
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 {
|
||||
ResolvedSetting::from_model(
|
||||
websocket_request.setting_send_cookies.value,
|
||||
|
||||
@@ -21,6 +21,7 @@ impl<'a> ClientDb<'a> {
|
||||
&Workspace {
|
||||
name: "Yaak".to_string(),
|
||||
setting_follow_redirects: true,
|
||||
setting_request_message_size: crate::models::DEFAULT_REQUEST_MESSAGE_SIZE,
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -102,6 +103,10 @@ impl<'a> ClientDb<'a> {
|
||||
workspace.setting_request_timeout,
|
||||
AnyModel::Workspace(workspace.clone()),
|
||||
),
|
||||
request_message_size: ResolvedSetting::from_model(
|
||||
workspace.setting_request_message_size,
|
||||
AnyModel::Workspace(workspace.clone()),
|
||||
),
|
||||
send_cookies: ResolvedSetting::from_model(
|
||||
workspace.setting_send_cookies,
|
||||
AnyModel::Workspace(workspace.clone()),
|
||||
|
||||
+4
@@ -108,6 +108,7 @@ export type Folder = {
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type GraphQlIntrospection = {
|
||||
@@ -183,6 +184,7 @@ export type GrpcRequest = {
|
||||
*/
|
||||
url: string;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type HttpRequest = {
|
||||
@@ -450,6 +452,7 @@ export type WebsocketRequest = {
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type Workspace = {
|
||||
@@ -466,6 +469,7 @@ export type Workspace = {
|
||||
settingValidateCertificates: boolean;
|
||||
settingFollowRedirects: boolean;
|
||||
settingRequestTimeout: number;
|
||||
settingRequestMessageSize: number;
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::api::{PluginVersion, download_plugin_archive, get_plugin};
|
||||
use crate::checksum::compute_checksum;
|
||||
use crate::error::Error::PluginErr;
|
||||
use crate::error::Error::{PluginErr, PluginNotFoundErr};
|
||||
use crate::error::Result;
|
||||
use crate::events::PluginContext;
|
||||
use crate::manager::PluginManager;
|
||||
@@ -29,7 +29,14 @@ pub async fn delete_and_uninstall(
|
||||
let db = query_manager.connect();
|
||||
db.delete_plugin_by_id(plugin_id, &update_source)?
|
||||
};
|
||||
plugin_manager.uninstall(plugin_context, plugin.directory.as_str()).await?;
|
||||
if let Err(err) = plugin_manager
|
||||
.uninstall(plugin_context, plugin.directory.as_str())
|
||||
.await
|
||||
{
|
||||
if !matches!(err, PluginNotFoundErr(_)) {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
Ok(plugin)
|
||||
}
|
||||
|
||||
|
||||
Generated
+4
@@ -46,6 +46,7 @@ export type Folder = {
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type GrpcRequest = {
|
||||
@@ -69,6 +70,7 @@ export type GrpcRequest = {
|
||||
*/
|
||||
url: string;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type HttpRequest = {
|
||||
@@ -159,6 +161,7 @@ export type WebsocketRequest = {
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type Workspace = {
|
||||
@@ -175,6 +178,7 @@ export type Workspace = {
|
||||
settingValidateCertificates: boolean;
|
||||
settingFollowRedirects: boolean;
|
||||
settingRequestTimeout: number;
|
||||
settingRequestMessageSize: number;
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
|
||||
@@ -20,6 +20,7 @@ pub async fn ws_connect(
|
||||
headers: HeaderMap<HeaderValue>,
|
||||
validate_certificates: bool,
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
request_message_size: i32,
|
||||
) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response)> {
|
||||
info!("Connecting to WS {url}");
|
||||
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(
|
||||
req,
|
||||
Some(WebSocketConfig::default()),
|
||||
Some(websocket_config(request_message_size)),
|
||||
false,
|
||||
Some(Connector::Rustls(Arc::new(tls_config))),
|
||||
)
|
||||
@@ -48,3 +49,12 @@ pub async fn ws_connect(
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 futures_util::stream::SplitSink;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
@@ -15,10 +16,16 @@ use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
||||
use yaak_tls::ClientCertificateConfig;
|
||||
|
||||
type WebsocketSink = SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>;
|
||||
|
||||
struct WebsocketConnection {
|
||||
max_message_size: Option<usize>,
|
||||
sink: WebsocketSink,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WebsocketManager {
|
||||
connections:
|
||||
Arc<Mutex<HashMap<String, SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>>,
|
||||
connections: Arc<Mutex<HashMap<String, WebsocketConnection>>>,
|
||||
read_tasks: Arc<Mutex<HashMap<String, tokio::task::JoinHandle<()>>>>,
|
||||
}
|
||||
|
||||
@@ -35,14 +42,20 @@ impl WebsocketManager {
|
||||
receive_tx: mpsc::Sender<Message>,
|
||||
validate_certificates: bool,
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
request_message_size: i32,
|
||||
) -> Result<Response> {
|
||||
let tx = receive_tx.clone();
|
||||
let max_message_size = message_size_limit(request_message_size);
|
||||
|
||||
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();
|
||||
|
||||
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 connection_id = id.to_string();
|
||||
@@ -76,7 +89,15 @@ impl WebsocketManager {
|
||||
None => return Ok(()),
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -84,7 +105,7 @@ impl WebsocketManager {
|
||||
info!("Closing websocket");
|
||||
if let Some(mut connection) = self.connections.lock().await.remove(id) {
|
||||
// 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:?}");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync", "rt"] }
|
||||
yaak-http = { workspace = true }
|
||||
yaak-core = { workspace = true }
|
||||
yaak-crypto = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
yaak-plugins = { workspace = true }
|
||||
|
||||
@@ -4,6 +4,18 @@ use thiserror::Error;
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Send(#[from] crate::send::SendHttpRequestError),
|
||||
|
||||
#[error(transparent)]
|
||||
Model(#[from] yaak_models::error::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Plugin(#[from] yaak_plugins::error::Error),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
use crate::Result;
|
||||
use std::fs::File;
|
||||
use std::path::Path;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::get_workspace_export_resources;
|
||||
|
||||
pub struct ExportDataParams<'a> {
|
||||
pub query_manager: &'a QueryManager,
|
||||
pub yaak_version: &'a str,
|
||||
pub export_path: &'a Path,
|
||||
pub workspace_ids: Vec<&'a str>,
|
||||
pub include_private_environments: bool,
|
||||
}
|
||||
|
||||
pub fn export_data(params: ExportDataParams<'_>) -> Result<()> {
|
||||
let db = params.query_manager.connect();
|
||||
let export_data = get_workspace_export_resources(
|
||||
&db,
|
||||
params.yaak_version,
|
||||
params.workspace_ids,
|
||||
params.include_private_environments,
|
||||
)?;
|
||||
|
||||
let file = File::options().create(true).truncate(true).write(true).open(params.export_path)?;
|
||||
serde_json::to_writer_pretty(&file, &export_data)?;
|
||||
file.sync_all()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
use crate::Result;
|
||||
use log::info;
|
||||
use std::collections::BTreeMap;
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_models::models::{
|
||||
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
|
||||
};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
|
||||
use yaak_plugins::events::{ImportResources, PluginContext};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
pub struct ImportDataParams<'a> {
|
||||
pub query_manager: &'a QueryManager,
|
||||
pub plugin_manager: &'a PluginManager,
|
||||
pub plugin_context: &'a PluginContext,
|
||||
pub workspace_context: WorkspaceContext,
|
||||
pub contents: &'a str,
|
||||
}
|
||||
|
||||
pub async fn import_data(params: ImportDataParams<'_>) -> Result<BatchUpsertResult> {
|
||||
let import_result =
|
||||
params.plugin_manager.import_data(params.plugin_context, params.contents).await?;
|
||||
|
||||
import_resources(params.query_manager, params.workspace_context, import_result.resources)
|
||||
}
|
||||
|
||||
pub fn import_resources(
|
||||
query_manager: &QueryManager,
|
||||
workspace_context: WorkspaceContext,
|
||||
resources: ImportResources,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
let mut id_map: BTreeMap<String, String> = BTreeMap::new();
|
||||
|
||||
let workspaces: Vec<Workspace> = resources
|
||||
.workspaces
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<Workspace>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
let environments: Vec<Environment> = resources
|
||||
.environments
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<Environment>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id =
|
||||
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
|
||||
match (v.parent_model.as_str(), v.parent_id.clone().as_deref()) {
|
||||
("folder", Some(parent_id)) => {
|
||||
v.parent_id =
|
||||
Some(maybe_gen_id::<Folder>(&workspace_context, parent_id, &mut id_map));
|
||||
}
|
||||
("", _) => {
|
||||
v.parent_model = "workspace".to_string();
|
||||
}
|
||||
_ => {
|
||||
v.parent_id = None;
|
||||
}
|
||||
};
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
let folders: Vec<Folder> = resources
|
||||
.folders
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<Folder>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id =
|
||||
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
|
||||
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
let http_requests: Vec<HttpRequest> = resources
|
||||
.http_requests
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<HttpRequest>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id =
|
||||
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
|
||||
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
let grpc_requests: Vec<GrpcRequest> = resources
|
||||
.grpc_requests
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<GrpcRequest>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id =
|
||||
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
|
||||
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
let websocket_requests: Vec<WebsocketRequest> = resources
|
||||
.websocket_requests
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<WebsocketRequest>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id =
|
||||
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
|
||||
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
info!("Importing data");
|
||||
|
||||
query_manager.with_tx(|tx| {
|
||||
tx.batch_upsert(
|
||||
workspaces,
|
||||
environments,
|
||||
folders,
|
||||
http_requests,
|
||||
grpc_requests,
|
||||
websocket_requests,
|
||||
&UpdateSource::Import,
|
||||
)
|
||||
.map_err(crate::Error::from)
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod error;
|
||||
pub mod export;
|
||||
pub mod import;
|
||||
pub mod plugin_events;
|
||||
pub mod render;
|
||||
pub mod send;
|
||||
|
||||
Generated
+18
@@ -16970,6 +16970,7 @@
|
||||
"ws": "^8.20.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.13",
|
||||
"@types/ws": "^8.5.13"
|
||||
}
|
||||
},
|
||||
@@ -16991,6 +16992,23 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"packages/plugin-runtime/node_modules/@types/node": {
|
||||
"version": "24.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"packages/plugin-runtime/node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/tailwind-config": {
|
||||
"name": "@yaakapp-internal/tailwind-config",
|
||||
"version": "1.0.0",
|
||||
|
||||
@@ -108,6 +108,7 @@ export type Folder = {
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type GraphQlIntrospection = {
|
||||
@@ -183,6 +184,7 @@ export type GrpcRequest = {
|
||||
*/
|
||||
url: string;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type HttpRequest = {
|
||||
@@ -450,6 +452,7 @@ export type WebsocketRequest = {
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type Workspace = {
|
||||
@@ -466,6 +469,7 @@ export type Workspace = {
|
||||
settingValidateCertificates: boolean;
|
||||
settingFollowRedirects: boolean;
|
||||
settingRequestTimeout: number;
|
||||
settingRequestMessageSize: number;
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
24.11.1
|
||||
@@ -9,6 +9,7 @@
|
||||
"ws": "^8.20.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.13",
|
||||
"@types/ws": "^8.5.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import type { PluginDefinition } from "@yaakapp/api";
|
||||
|
||||
type PluginFeatureKey = Exclude<
|
||||
Extract<keyof PluginDefinition, string>,
|
||||
"init" | "dispose"
|
||||
>;
|
||||
type PluginAPIKey = PluginFeatureKey | "lifecycle";
|
||||
|
||||
type MetadataDefinition = {
|
||||
key: PluginFeatureKey;
|
||||
label: string;
|
||||
array: boolean;
|
||||
};
|
||||
|
||||
type MetadataItem =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| MetadataItem[]
|
||||
| { [key: string]: MetadataItem };
|
||||
|
||||
type APITypeMetadata = {
|
||||
label: string;
|
||||
source: string;
|
||||
count: number;
|
||||
items: MetadataItem[];
|
||||
};
|
||||
|
||||
type PluginMetadata = {
|
||||
schemaVersion: 1;
|
||||
apiTypes: PluginAPIKey[];
|
||||
apis: Partial<Record<PluginAPIKey, APITypeMetadata>>;
|
||||
};
|
||||
|
||||
const definitions: MetadataDefinition[] = [
|
||||
{
|
||||
key: "authentication",
|
||||
label: "Authentication",
|
||||
array: false,
|
||||
},
|
||||
{ key: "filter", label: "Filter", array: false },
|
||||
{
|
||||
key: "folderActions",
|
||||
label: "Folder Action",
|
||||
array: true,
|
||||
},
|
||||
{
|
||||
key: "grpcRequestActions",
|
||||
label: "gRPC Request Action",
|
||||
array: true,
|
||||
},
|
||||
{
|
||||
key: "httpRequestActions",
|
||||
label: "HTTP Request Action",
|
||||
array: true,
|
||||
},
|
||||
{ key: "importer", label: "Importer", array: false },
|
||||
{
|
||||
key: "templateFunctions",
|
||||
label: "Template Tag",
|
||||
array: true,
|
||||
},
|
||||
{ key: "themes", label: "Theme", array: true },
|
||||
{
|
||||
key: "websocketRequestActions",
|
||||
label: "WebSocket Request Action",
|
||||
array: true,
|
||||
},
|
||||
{
|
||||
key: "workspaceActions",
|
||||
label: "Workspace Action",
|
||||
array: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function generatePluginMetadata(
|
||||
plugin: PluginDefinition,
|
||||
): PluginMetadata {
|
||||
const metadata: PluginMetadata = {
|
||||
schemaVersion: 1,
|
||||
apiTypes: [],
|
||||
apis: {},
|
||||
};
|
||||
|
||||
for (const definition of definitions) {
|
||||
const value = plugin[definition.key];
|
||||
const items = definition.array ? value : value ? [value] : [];
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
metadata.apiTypes.push(definition.key);
|
||||
metadata.apis[definition.key] = {
|
||||
label: definition.label,
|
||||
source: definition.key,
|
||||
count: items.length,
|
||||
items: sanitize(items) as MetadataItem[],
|
||||
};
|
||||
}
|
||||
|
||||
const lifecycleHooks = ["init", "dispose"].filter(
|
||||
(key) =>
|
||||
typeof plugin[key as keyof Pick<PluginDefinition, "init" | "dispose">] ===
|
||||
"function",
|
||||
);
|
||||
if (lifecycleHooks.length > 0) {
|
||||
metadata.apiTypes.push("lifecycle");
|
||||
metadata.apis.lifecycle = {
|
||||
label: "Lifecycle Hook",
|
||||
source: lifecycleHooks.join(","),
|
||||
count: lifecycleHooks.length,
|
||||
items: lifecycleHooks.map((name) => ({ name })),
|
||||
};
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
const entryPath = process.argv[1];
|
||||
const outputPath = process.argv[2];
|
||||
|
||||
if (!entryPath) {
|
||||
throw new Error("Missing plugin entrypoint path");
|
||||
}
|
||||
if (!outputPath) {
|
||||
throw new Error("Missing plugin metadata output path");
|
||||
}
|
||||
|
||||
const require = createRequire(path.join(process.cwd(), "plugin-metadata.js"));
|
||||
const moduleExports = require(path.resolve(entryPath)) as PluginDefinition & {
|
||||
plugin?: PluginDefinition;
|
||||
default?: PluginDefinition;
|
||||
};
|
||||
const plugin = moduleExports.plugin ?? moduleExports.default ?? moduleExports;
|
||||
|
||||
if (!plugin || typeof plugin !== "object") {
|
||||
throw new Error("Plugin entrypoint must export a plugin object");
|
||||
}
|
||||
|
||||
const metadata = generatePluginMetadata(plugin);
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(metadata, null, 2)}\n`);
|
||||
|
||||
function sanitize(
|
||||
value: unknown,
|
||||
seen = new WeakSet<object>(),
|
||||
): MetadataItem | undefined {
|
||||
if (value === null) return null;
|
||||
|
||||
switch (typeof value) {
|
||||
case "boolean":
|
||||
case "number":
|
||||
case "string":
|
||||
return value;
|
||||
case "bigint":
|
||||
return value.toString();
|
||||
case "function":
|
||||
case "symbol":
|
||||
case "undefined":
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const objectValue = value as object;
|
||||
if (seen.has(objectValue)) {
|
||||
return "[Circular]";
|
||||
}
|
||||
|
||||
seen.add(objectValue);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const output = value.map((item) => sanitize(item, seen) ?? null);
|
||||
seen.delete(objectValue);
|
||||
return output;
|
||||
}
|
||||
|
||||
const output: Record<string, MetadataItem> = {};
|
||||
for (const [key, item] of Object.entries(objectValue)) {
|
||||
const sanitized = sanitize(item, seen);
|
||||
if (sanitized !== undefined) {
|
||||
output[key] = sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
seen.delete(objectValue);
|
||||
return output;
|
||||
}
|
||||
@@ -12,6 +12,9 @@ export type { DocumentPlatform, YaakColorKey, YaakColors, YaakTheme } from "./wi
|
||||
export {
|
||||
addThemeStylesToDocument,
|
||||
applyThemeToDocument,
|
||||
completeColorVariables,
|
||||
completeFullColorVariables,
|
||||
completePartialColorVariables,
|
||||
completeTheme,
|
||||
getThemeCSS,
|
||||
indent,
|
||||
|
||||
+128
-108
@@ -47,18 +47,10 @@ export type YaakTheme = {
|
||||
export type YaakColorKey = keyof ThemeComponentColors;
|
||||
export type DocumentPlatform = "linux" | "macos" | "windows" | "unknown";
|
||||
|
||||
type ComponentName = keyof NonNullable<YaakTheme["components"]>;
|
||||
type ComponentName = keyof NonNullable<Theme["components"]>;
|
||||
type CSSVariables = Record<YaakColorKey, string | undefined>;
|
||||
|
||||
function themeVariables(
|
||||
theme: Theme,
|
||||
component?: ComponentName,
|
||||
base?: CSSVariables,
|
||||
): CSSVariables | null {
|
||||
const cmp =
|
||||
component == null
|
||||
? theme.base
|
||||
: (theme.components?.[component] ?? ({} as ThemeComponentColors));
|
||||
export function completeFullColorVariables(theme: Theme, cmp: Partial<CSSVariables>): CSSVariables {
|
||||
const color = (value: string | undefined) => yc(theme, value);
|
||||
const vars: CSSVariables = {
|
||||
surface: cmp.surface,
|
||||
@@ -66,12 +58,12 @@ function themeVariables(
|
||||
surfaceActive: cmp.surfaceActive ?? color(cmp.primary)?.lower(0.2).translucify(0.8).css(),
|
||||
backdrop: cmp.backdrop ?? color(cmp.surface)?.lower(0.2).translucify(0.2).css(),
|
||||
selection: cmp.selection ?? color(cmp.primary)?.lower(0.1).translucify(0.7).css(),
|
||||
border: cmp.border ?? color(cmp.surface)?.lift(0.11)?.css(),
|
||||
borderSubtle: cmp.borderSubtle ?? color(cmp.border)?.lower(0.06)?.css(),
|
||||
borderFocus: color(cmp.info)?.translucify(0.5)?.css(),
|
||||
border: cmp.border,
|
||||
borderSubtle: cmp.borderSubtle,
|
||||
borderFocus: cmp.borderFocus ?? color(cmp.info)?.translucify(0.5)?.css(),
|
||||
text: cmp.text,
|
||||
textSubtle: cmp.textSubtle ?? color(cmp.text)?.lower(0.2)?.css(),
|
||||
textSubtlest: cmp.textSubtlest ?? color(cmp.text)?.lower(0.3)?.css(),
|
||||
textSubtle: cmp.textSubtle,
|
||||
textSubtlest: cmp.textSubtlest,
|
||||
shadow:
|
||||
cmp.shadow ??
|
||||
YaakColor.black()
|
||||
@@ -86,95 +78,126 @@ function themeVariables(
|
||||
danger: cmp.danger,
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
if (!value && base?.[key as YaakColorKey]) {
|
||||
vars[key as YaakColorKey] = base[key as YaakColorKey];
|
||||
}
|
||||
}
|
||||
const themeColor = (value: string) => new YaakColor(value, theme.dark ? "dark" : "light");
|
||||
const themeSurface = themeColor(theme.dark ? "oklch(23% 0 0)" : "oklch(100% 0 0)");
|
||||
const surface = themeColor(vars.surface ?? themeSurface.css());
|
||||
const reference = surface.compositeOver(themeSurface);
|
||||
const seed = themeColor(vars.surface ?? vars.surfaceHighlight ?? vars.border ?? surface.css());
|
||||
const textBase = seed.desaturate(0.6).opacify(1);
|
||||
const borderBase = seed.opacify(1);
|
||||
const text = vars.text ?? textBase.withContrast(reference, 11).css();
|
||||
const textColor = themeColor(text);
|
||||
|
||||
return vars;
|
||||
return normalizeColorVariables(theme, {
|
||||
...vars,
|
||||
text,
|
||||
textSubtle: vars.textSubtle ?? textColor.lower(0.2).css(),
|
||||
textSubtlest: vars.textSubtlest ?? textColor.lower(0.4).css(),
|
||||
border: vars.border ?? borderBase.desaturate(0.2).withContrast(reference, 3).css(),
|
||||
borderSubtle:
|
||||
vars.borderSubtle ?? borderBase.desaturate(0.2).withContrast(reference, 1.2).css(),
|
||||
});
|
||||
}
|
||||
|
||||
function templateTagColorVariables(color: YaakColor | null): Partial<CSSVariables> {
|
||||
if (color == null) return {};
|
||||
export function completePartialColorVariables(
|
||||
theme: Theme,
|
||||
cmp: Partial<CSSVariables>,
|
||||
): CSSVariables {
|
||||
const color = (value: string | undefined) => yc(theme, value);
|
||||
const text = color(cmp.text);
|
||||
|
||||
return {
|
||||
text: color.lift(0.7).css(),
|
||||
textSubtle: color.lift(0.4).css(),
|
||||
return normalizeColorVariables(theme, {
|
||||
surface: cmp.surface,
|
||||
surfaceHighlight: cmp.surfaceHighlight ?? color(cmp.surface)?.lift(0.06).css(),
|
||||
surfaceActive: cmp.surfaceActive ?? color(cmp.primary)?.lower(0.2).translucify(0.8).css(),
|
||||
backdrop: cmp.backdrop ?? color(cmp.surface)?.lower(0.2).translucify(0.2).css(),
|
||||
selection: cmp.selection ?? color(cmp.primary)?.lower(0.1).translucify(0.7).css(),
|
||||
border: cmp.border ?? color(cmp.surface)?.lift(0.11).css(),
|
||||
borderSubtle: cmp.borderSubtle ?? color(cmp.border)?.lower(0.06).css(),
|
||||
borderFocus: cmp.borderFocus ?? color(cmp.info)?.translucify(0.5).css(),
|
||||
text: cmp.text,
|
||||
textSubtle: cmp.textSubtle ?? text?.lower(0.3).css(),
|
||||
textSubtlest: cmp.textSubtlest ?? text?.lower(0.5).css(),
|
||||
shadow:
|
||||
cmp.shadow ??
|
||||
YaakColor.black()
|
||||
.translucify(theme.dark ? 0.7 : 0.93)
|
||||
.css(),
|
||||
primary: cmp.primary,
|
||||
secondary: cmp.secondary,
|
||||
info: cmp.info,
|
||||
success: cmp.success,
|
||||
notice: cmp.notice,
|
||||
warning: cmp.warning,
|
||||
danger: cmp.danger,
|
||||
});
|
||||
}
|
||||
|
||||
export const completeColorVariables = completeFullColorVariables;
|
||||
|
||||
function normalizeColorVariables(theme: Theme, vars: CSSVariables): CSSVariables {
|
||||
const normalized: CSSVariables = {} as CSSVariables;
|
||||
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
normalized[key as YaakColorKey] = value == null ? undefined : yc(theme, value).css();
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function templateTagColorVariables(theme: Theme, color: YaakColor): CSSVariables {
|
||||
return completeFullColorVariables(theme, {
|
||||
text: color.liftMax().lower(0.05).css(),
|
||||
textSubtle: color.liftMax().lower(0.08).css(),
|
||||
textSubtlest: color.css(),
|
||||
surface: color.lower(0.2).translucify(0.8).css(),
|
||||
border: color.translucify(0.6).css(),
|
||||
borderSubtle: color.translucify(0.8).css(),
|
||||
surfaceHighlight: color.lower(0.1).translucify(0.7).css(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function toastColorVariables(color: YaakColor | null): Partial<CSSVariables> {
|
||||
if (color == null) return {};
|
||||
|
||||
return {
|
||||
text: color.lift(0.8).css(),
|
||||
textSubtle: color.lift(0.8).translucify(0.3).css(),
|
||||
function toastColorVariables(theme: Theme, color: YaakColor): CSSVariables {
|
||||
return completeFullColorVariables(theme, {
|
||||
surface: color.translucify(0.9).css(),
|
||||
surfaceHighlight: color.translucify(0.8).css(),
|
||||
border: color.lift(0.3).translucify(0.6).css(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function bannerColorVariables(color: YaakColor | null): Partial<CSSVariables> {
|
||||
if (color == null) return {};
|
||||
|
||||
return {
|
||||
text: color.lift(0.8).css(),
|
||||
textSubtle: color.translucify(0.3).css(),
|
||||
textSubtlest: color.translucify(0.6).css(),
|
||||
function bannerColorVariables(theme: Theme, color: YaakColor): CSSVariables {
|
||||
return completeFullColorVariables(theme, {
|
||||
surface: color.translucify(0.95).css(),
|
||||
surfaceHighlight: color.translucify(0.85).css(),
|
||||
border: color.lift(0.3).translucify(0.8).css(),
|
||||
};
|
||||
}
|
||||
|
||||
function _inputCSS(color: YaakColor | null): Partial<CSSVariables> {
|
||||
if (color == null) return {};
|
||||
|
||||
const theme: Partial<ThemeComponentColors> = {
|
||||
border: color.css(),
|
||||
};
|
||||
|
||||
return theme;
|
||||
});
|
||||
}
|
||||
|
||||
function buttonSolidColorVariables(
|
||||
color: YaakColor | null,
|
||||
theme: Theme,
|
||||
color: YaakColor,
|
||||
isDefault = false,
|
||||
): Partial<CSSVariables> {
|
||||
if (color == null) return {};
|
||||
|
||||
const theme: Partial<ThemeComponentColors> = {
|
||||
text: "white",
|
||||
): CSSVariables {
|
||||
const vars: Partial<CSSVariables> = {
|
||||
surface: color.lower(0.3).css(),
|
||||
surfaceHighlight: color.lower(0.1).css(),
|
||||
border: color.css(),
|
||||
};
|
||||
|
||||
if (isDefault) {
|
||||
theme.text = undefined;
|
||||
theme.surface = undefined;
|
||||
theme.surfaceHighlight = color.lift(0.08).css();
|
||||
vars.surface = undefined;
|
||||
vars.surfaceHighlight = color.lift(0.08).css();
|
||||
}
|
||||
|
||||
return theme;
|
||||
return completeFullColorVariables(theme, vars);
|
||||
}
|
||||
|
||||
function buttonBorderColorVariables(
|
||||
color: YaakColor | null,
|
||||
theme: Theme,
|
||||
color: YaakColor,
|
||||
isDefault = false,
|
||||
): Partial<CSSVariables> {
|
||||
if (color == null) return {};
|
||||
|
||||
): CSSVariables {
|
||||
const vars: Partial<CSSVariables> = {
|
||||
text: color.lift(0.8).css(),
|
||||
textSubtle: color.lift(0.55).css(),
|
||||
textSubtlest: color.lift(0.4).translucify(0.6).css(),
|
||||
text: color.desaturate(0.4).lift(1).css(),
|
||||
textSubtle: color.desaturate(0.4).lift(0.55).css(),
|
||||
surfaceHighlight: color.translucify(0.8).css(),
|
||||
borderSubtle: color.translucify(0.5).css(),
|
||||
border: color.translucify(0.3).css(),
|
||||
@@ -185,7 +208,7 @@ function buttonBorderColorVariables(
|
||||
vars.border = color.lift(0.5).css();
|
||||
}
|
||||
|
||||
return vars;
|
||||
return completeFullColorVariables(theme, vars);
|
||||
}
|
||||
|
||||
function variablesToCSS(
|
||||
@@ -202,9 +225,8 @@ function variablesToCSS(
|
||||
return selector == null ? css : `${selector} {\n${indent(css)}\n}`;
|
||||
}
|
||||
|
||||
function componentCSS(theme: Theme, component: ComponentName): string | null {
|
||||
if (theme.components == null) return null;
|
||||
return variablesToCSS(`.x-theme-${component}`, themeVariables(theme, component));
|
||||
function componentCSS(component: ComponentName, vars: CSSVariables): string | null {
|
||||
return variablesToCSS(`.x-theme-${component}`, vars);
|
||||
}
|
||||
|
||||
function buttonCSS(
|
||||
@@ -216,8 +238,11 @@ function buttonCSS(
|
||||
if (color == null) return null;
|
||||
|
||||
return [
|
||||
variablesToCSS(`.x-theme-button--solid--${colorKey}`, buttonSolidColorVariables(color)),
|
||||
variablesToCSS(`.x-theme-button--border--${colorKey}`, buttonBorderColorVariables(color)),
|
||||
variablesToCSS(`.x-theme-button--solid--${colorKey}`, buttonSolidColorVariables(theme, color)),
|
||||
variablesToCSS(
|
||||
`.x-theme-button--border--${colorKey}`,
|
||||
buttonBorderColorVariables(theme, color),
|
||||
),
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
@@ -229,7 +254,7 @@ function bannerCSS(
|
||||
const color = yc(theme, colors?.[colorKey]);
|
||||
if (color == null) return null;
|
||||
|
||||
return variablesToCSS(`.x-theme-banner--${colorKey}`, bannerColorVariables(color));
|
||||
return variablesToCSS(`.x-theme-banner--${colorKey}`, bannerColorVariables(theme, color));
|
||||
}
|
||||
|
||||
function toastCSS(
|
||||
@@ -240,7 +265,7 @@ function toastCSS(
|
||||
const color = yc(theme, colors?.[colorKey]);
|
||||
if (color == null) return null;
|
||||
|
||||
return variablesToCSS(`.x-theme-toast--${colorKey}`, toastColorVariables(color));
|
||||
return variablesToCSS(`.x-theme-toast--${colorKey}`, toastColorVariables(theme, color));
|
||||
}
|
||||
|
||||
function templateTagCSS(
|
||||
@@ -251,7 +276,10 @@ function templateTagCSS(
|
||||
const color = yc(theme, colors?.[colorKey]);
|
||||
if (color == null) return null;
|
||||
|
||||
return variablesToCSS(`.x-theme-templateTag--${colorKey}`, templateTagColorVariables(color));
|
||||
return variablesToCSS(
|
||||
`.x-theme-templateTag--${colorKey}`,
|
||||
templateTagColorVariables(theme, color),
|
||||
);
|
||||
}
|
||||
|
||||
export function getThemeCSS(theme: Theme): string {
|
||||
@@ -264,18 +292,26 @@ export function getThemeCSS(theme: Theme): string {
|
||||
|
||||
let themeCSS = "";
|
||||
try {
|
||||
const baseCss = variablesToCSS(null, themeVariables(theme));
|
||||
const baseCss = variablesToCSS(null, completeFullColorVariables(theme, theme.base));
|
||||
const baseSurface = yc(theme, theme.base.surface);
|
||||
|
||||
themeCSS = [
|
||||
baseCss,
|
||||
...Object.keys(components).map((key) => componentCSS(theme, key as ComponentName)),
|
||||
variablesToCSS(
|
||||
".x-theme-button--solid--default",
|
||||
buttonSolidColorVariables(yc(theme, theme.base.surface), true),
|
||||
),
|
||||
variablesToCSS(
|
||||
".x-theme-button--border--default",
|
||||
buttonBorderColorVariables(yc(theme, theme.base.surface), true),
|
||||
...Object.entries(components).map(([key, value]) =>
|
||||
componentCSS(key as ComponentName, completePartialColorVariables(theme, value ?? {})),
|
||||
),
|
||||
baseSurface == null
|
||||
? null
|
||||
: variablesToCSS(
|
||||
".x-theme-button--solid--default",
|
||||
buttonSolidColorVariables(theme, baseSurface, true),
|
||||
),
|
||||
baseSurface == null
|
||||
? null
|
||||
: variablesToCSS(
|
||||
".x-theme-button--border--default",
|
||||
buttonBorderColorVariables(theme, baseSurface, true),
|
||||
),
|
||||
...Object.keys(colors).map((key) =>
|
||||
buttonCSS(theme, key as YaakColorKey, theme.components?.button ?? colors),
|
||||
),
|
||||
@@ -360,26 +396,10 @@ function yc<T extends string | null | undefined>(
|
||||
|
||||
export function completeTheme(theme: Theme): Theme {
|
||||
const fallback = theme.dark ? defaultDarkTheme.base : defaultLightTheme.base;
|
||||
const color = (value: string | null | undefined) => yc(theme, value);
|
||||
|
||||
theme.base.primary ??= fallback.primary;
|
||||
theme.base.secondary ??= fallback.secondary;
|
||||
theme.base.info ??= fallback.info;
|
||||
theme.base.success ??= fallback.success;
|
||||
theme.base.notice ??= fallback.notice;
|
||||
theme.base.warning ??= fallback.warning;
|
||||
theme.base.danger ??= fallback.danger;
|
||||
|
||||
theme.base.surface ??= fallback.surface;
|
||||
theme.base.surfaceHighlight ??= color(theme.base.surface)?.lift(0.06)?.css();
|
||||
theme.base.surfaceActive ??= color(theme.base.primary)?.lower(0.2).translucify(0.8).css();
|
||||
|
||||
theme.base.border ??= color(theme.base.surface)?.lift(0.12)?.css();
|
||||
theme.base.borderSubtle ??= color(theme.base.border)?.lower(0.08)?.css();
|
||||
|
||||
theme.base.text ??= fallback.text;
|
||||
theme.base.textSubtle ??= color(theme.base.text)?.lower(0.3)?.css();
|
||||
theme.base.textSubtlest ??= color(theme.base.text)?.lower(0.5)?.css();
|
||||
for (const [key, value] of Object.entries(fallback)) {
|
||||
theme.base[key as YaakColorKey] ??= value;
|
||||
}
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
+254
-17
@@ -3,9 +3,9 @@ import parseColor from "parse-color";
|
||||
export class YaakColor {
|
||||
private readonly appearance: "dark" | "light" = "light";
|
||||
|
||||
private hue = 0;
|
||||
private saturation = 0;
|
||||
private lightness = 0;
|
||||
private chroma = 0;
|
||||
private hue = 0;
|
||||
private alpha = 1;
|
||||
|
||||
constructor(cssColor: string, appearance: "dark" | "light" = "light") {
|
||||
@@ -22,11 +22,11 @@ export class YaakColor {
|
||||
}
|
||||
|
||||
static white(): YaakColor {
|
||||
return new YaakColor("rgb(0,0,0)", "light").lower(1);
|
||||
return new YaakColor("rgb(0,0,0)", "light").lower(999);
|
||||
}
|
||||
|
||||
static black(): YaakColor {
|
||||
return new YaakColor("rgb(0,0,0)", "light").lift(1);
|
||||
return new YaakColor("rgb(0,0,0)", "light").lift(999);
|
||||
}
|
||||
|
||||
set(cssColor: string): YaakColor {
|
||||
@@ -35,11 +35,22 @@ export class YaakColor {
|
||||
const [r, g, b, a] = hexToRgba(cssColor);
|
||||
fixedCssColor = `rgba(${r},${g},${b},${a})`;
|
||||
}
|
||||
const { hsla } = parseColor(fixedCssColor);
|
||||
this.hue = hsla[0];
|
||||
this.saturation = hsla[1];
|
||||
this.lightness = hsla[2];
|
||||
this.alpha = hsla[3] ?? 1;
|
||||
|
||||
const oklch = parseOklch(fixedCssColor);
|
||||
if (oklch != null) {
|
||||
this.lightness = oklch.lightness;
|
||||
this.chroma = oklch.chroma;
|
||||
this.hue = oklch.hue;
|
||||
this.alpha = oklch.alpha;
|
||||
return this;
|
||||
}
|
||||
|
||||
const { rgba } = parseColor(fixedCssColor);
|
||||
const [lightness, chroma, hue] = rgbToOklch(rgba[0], rgba[1], rgba[2]);
|
||||
this.lightness = lightness;
|
||||
this.chroma = chroma;
|
||||
this.hue = hue;
|
||||
this.alpha = rgba[3] ?? 1;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -47,6 +58,10 @@ export class YaakColor {
|
||||
return new YaakColor(this.css(), this.appearance);
|
||||
}
|
||||
|
||||
themeColor(cssColor: string): YaakColor {
|
||||
return new YaakColor(cssColor, this.appearance);
|
||||
}
|
||||
|
||||
lower(mod: number): YaakColor {
|
||||
return this.appearance === "dark" ? this._darken(mod) : this._lighten(mod);
|
||||
}
|
||||
@@ -55,6 +70,21 @@ export class YaakColor {
|
||||
return this.appearance === "dark" ? this._lighten(mod) : this._darken(mod);
|
||||
}
|
||||
|
||||
liftMax(): YaakColor {
|
||||
return this.lift(999);
|
||||
}
|
||||
|
||||
lowerMax(): YaakColor {
|
||||
return this.lower(999);
|
||||
}
|
||||
|
||||
themeSurface(): YaakColor {
|
||||
return new YaakColor(
|
||||
this.appearance === "dark" ? "oklch(23% 0 0)" : "oklch(100% 0 0)",
|
||||
this.appearance,
|
||||
);
|
||||
}
|
||||
|
||||
minLightness(n: number): YaakColor {
|
||||
const color = this.clone();
|
||||
if (color.lightness < n) {
|
||||
@@ -69,25 +99,25 @@ export class YaakColor {
|
||||
|
||||
translucify(mod: number): YaakColor {
|
||||
const color = this.clone();
|
||||
color.alpha = color.alpha - color.alpha * mod;
|
||||
color.alpha = clamp(color.alpha - color.alpha * mod, 0, 1);
|
||||
return color;
|
||||
}
|
||||
|
||||
opacify(mod: number): YaakColor {
|
||||
const color = this.clone();
|
||||
color.alpha = this.alpha + (100 - this.alpha) * mod;
|
||||
color.alpha = clamp(this.alpha + (1 - this.alpha) * mod, 0, 1);
|
||||
return color;
|
||||
}
|
||||
|
||||
desaturate(mod: number): YaakColor {
|
||||
const color = this.clone();
|
||||
color.saturation = color.saturation - color.saturation * mod;
|
||||
color.chroma = color.chroma - color.chroma * mod;
|
||||
return color;
|
||||
}
|
||||
|
||||
saturate(mod: number): YaakColor {
|
||||
const color = this.clone();
|
||||
color.saturation = this.saturation + (100 - this.saturation) * mod;
|
||||
color.chroma = this.chroma + this.chroma * mod;
|
||||
return color;
|
||||
}
|
||||
|
||||
@@ -95,29 +125,236 @@ export class YaakColor {
|
||||
return this.lightness > color.lightness;
|
||||
}
|
||||
|
||||
contrastRatio(background: YaakColor): number {
|
||||
const foreground = this.alpha < 1 ? this.compositeOver(background) : this;
|
||||
const foregroundLuminance = foreground.relativeLuminance();
|
||||
const backgroundLuminance = background.relativeLuminance();
|
||||
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
|
||||
const darker = Math.min(foregroundLuminance, backgroundLuminance);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
withContrast(background: YaakColor, minContrast: number): YaakColor {
|
||||
const darker = this.clone();
|
||||
darker.lightness = 0;
|
||||
darker.chroma = 0;
|
||||
darker.hue = 0;
|
||||
|
||||
const lighter = this.clone();
|
||||
lighter.lightness = 100;
|
||||
lighter.chroma = 0;
|
||||
lighter.hue = 0;
|
||||
|
||||
const darkerContrast = darker.contrastRatio(background);
|
||||
const lighterContrast = lighter.contrastRatio(background);
|
||||
let useLighterColor = lighterContrast >= darkerContrast;
|
||||
|
||||
// Saturated accent surfaces often read better with white text even when
|
||||
// black has the higher numeric contrast. Keep yellow-ish light accents dark
|
||||
// by requiring white to clear a modest contrast floor first.
|
||||
if (minContrast >= 3 && lighterContrast >= 2.5) {
|
||||
useLighterColor = true;
|
||||
}
|
||||
|
||||
const selectedContrast = useLighterColor ? lighterContrast : darkerContrast;
|
||||
if (selectedContrast < minContrast) {
|
||||
return useLighterColor ? lighter : darker;
|
||||
}
|
||||
|
||||
let minLightness = 0;
|
||||
let maxLightness = 100;
|
||||
const color = this.clone();
|
||||
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
color.lightness = (minLightness + maxLightness) / 2;
|
||||
const contrast = color.contrastRatio(background);
|
||||
|
||||
if (useLighterColor) {
|
||||
if (contrast >= minContrast) {
|
||||
maxLightness = color.lightness;
|
||||
} else {
|
||||
minLightness = color.lightness;
|
||||
}
|
||||
} else if (contrast >= minContrast) {
|
||||
minLightness = color.lightness;
|
||||
} else {
|
||||
maxLightness = color.lightness;
|
||||
}
|
||||
}
|
||||
|
||||
color.lightness = useLighterColor ? maxLightness : minLightness;
|
||||
return color;
|
||||
}
|
||||
|
||||
compositeOver(background: YaakColor): YaakColor {
|
||||
const [fgR, fgG, fgB] = this.rgb();
|
||||
const [bgR, bgG, bgB] = background.rgb();
|
||||
const alpha = this.alpha + background.alpha * (1 - this.alpha);
|
||||
|
||||
if (alpha <= 0) {
|
||||
return YaakColor.transparent();
|
||||
}
|
||||
|
||||
const r = (fgR * this.alpha + bgR * background.alpha * (1 - this.alpha)) / alpha;
|
||||
const g = (fgG * this.alpha + bgG * background.alpha * (1 - this.alpha)) / alpha;
|
||||
const b = (fgB * this.alpha + bgB * background.alpha * (1 - this.alpha)) / alpha;
|
||||
|
||||
return new YaakColor(`rgba(${r},${g},${b},${alpha})`, this.appearance);
|
||||
}
|
||||
|
||||
css(): string {
|
||||
const [r, g, b] = parseColor(`hsl(${this.hue},${this.saturation}%,${this.lightness}%)`).rgb;
|
||||
const [r, g, b] = this.rgb();
|
||||
return rgbaToHex(r, g, b, this.alpha);
|
||||
}
|
||||
|
||||
hexNoAlpha(): string {
|
||||
const [r, g, b] = parseColor(`hsl(${this.hue},${this.saturation}%,${this.lightness}%)`).rgb;
|
||||
const [r, g, b] = this.rgb();
|
||||
return rgbaToHexNoAlpha(r, g, b);
|
||||
}
|
||||
|
||||
private relativeLuminance(): number {
|
||||
const [r, g, b] = this.rgb();
|
||||
const red = srgbToLinear(r / 255);
|
||||
const green = srgbToLinear(g / 255);
|
||||
const blue = srgbToLinear(b / 255);
|
||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||||
}
|
||||
|
||||
private rgb(): [number, number, number] {
|
||||
return oklchToRgb(this.lightness, this.chroma, this.hue);
|
||||
}
|
||||
|
||||
private _lighten(mod: number): YaakColor {
|
||||
const color = this.clone();
|
||||
color.lightness = this.lightness + (100 - this.lightness) * mod;
|
||||
color.lightness = clamp(this.lightness + (100 - this.lightness) * mod, 0, 100);
|
||||
return color;
|
||||
}
|
||||
|
||||
private _darken(mod: number): YaakColor {
|
||||
const color = this.clone();
|
||||
color.lightness = this.lightness - this.lightness * mod;
|
||||
color.lightness = clamp(this.lightness - this.lightness * mod, 0, 100);
|
||||
return color;
|
||||
}
|
||||
}
|
||||
|
||||
function parseOklch(
|
||||
cssColor: string,
|
||||
): { lightness: number; chroma: number; hue: number; alpha: number } | null {
|
||||
const match = cssColor
|
||||
.trim()
|
||||
.match(
|
||||
/^oklch\(\s*([^\s,]+)(?:\s+|,\s*)([^\s,]+)(?:\s+|,\s*)([^\s,/]+)(?:\s*\/\s*([^)]+)|(?:\s*,\s*([^)]*))?)\s*\)$/i,
|
||||
);
|
||||
if (match == null) return null;
|
||||
|
||||
const [, lightnessValue, chromaValue, hueValue, slashAlpha, commaAlpha] = match;
|
||||
if (lightnessValue == null || chromaValue == null || hueValue == null) return null;
|
||||
|
||||
const lightness = parseOklchLightness(lightnessValue);
|
||||
const chroma = parseCssNumber(chromaValue, 1);
|
||||
const hue = normalizeHue(parseCssNumber(hueValue.replace(/deg$/i, ""), 1));
|
||||
const alpha = parseCssNumber(slashAlpha ?? commaAlpha ?? "1", 1);
|
||||
|
||||
if (
|
||||
!Number.isFinite(lightness) ||
|
||||
!Number.isFinite(chroma) ||
|
||||
!Number.isFinite(hue) ||
|
||||
!Number.isFinite(alpha)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
lightness: clamp(lightness, 0, 100),
|
||||
chroma: Math.max(0, chroma),
|
||||
hue,
|
||||
alpha: clamp(alpha, 0, 1),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCssNumber(value: string, percentScale: number): number {
|
||||
const normalized = value.trim();
|
||||
if (normalized.endsWith("%")) {
|
||||
return (Number.parseFloat(normalized) / 100) * percentScale;
|
||||
}
|
||||
return Number.parseFloat(normalized);
|
||||
}
|
||||
|
||||
function parseOklchLightness(value: string): number {
|
||||
const parsed = parseCssNumber(value, 100);
|
||||
return value.trim().endsWith("%") || parsed > 1 ? parsed : parsed * 100;
|
||||
}
|
||||
|
||||
function rgbToOklch(r: number, g: number, b: number): [number, number, number] {
|
||||
const red = srgbToLinear(r / 255);
|
||||
const green = srgbToLinear(g / 255);
|
||||
const blue = srgbToLinear(b / 255);
|
||||
|
||||
const l = 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue;
|
||||
const m = 0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue;
|
||||
const s = 0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue;
|
||||
|
||||
const lRoot = Math.cbrt(l);
|
||||
const mRoot = Math.cbrt(m);
|
||||
const sRoot = Math.cbrt(s);
|
||||
|
||||
const lightness = 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot;
|
||||
const a = 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot;
|
||||
const okb = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot;
|
||||
|
||||
return [
|
||||
lightness * 100,
|
||||
Math.sqrt(a * a + okb * okb),
|
||||
normalizeHue(radToDeg(Math.atan2(okb, a))),
|
||||
];
|
||||
}
|
||||
|
||||
function oklchToRgb(lightness: number, chroma: number, hue: number): [number, number, number] {
|
||||
const l = clamp(lightness, 0, 100) / 100;
|
||||
const a = Math.cos(degToRad(hue)) * chroma;
|
||||
const b = Math.sin(degToRad(hue)) * chroma;
|
||||
|
||||
const lRoot = l + 0.3963377774 * a + 0.2158037573 * b;
|
||||
const mRoot = l - 0.1055613458 * a - 0.0638541728 * b;
|
||||
const sRoot = l - 0.0894841775 * a - 1.291485548 * b;
|
||||
|
||||
const lCube = lRoot * lRoot * lRoot;
|
||||
const mCube = mRoot * mRoot * mRoot;
|
||||
const sCube = sRoot * sRoot * sRoot;
|
||||
|
||||
const red = 4.0767416621 * lCube - 3.3077115913 * mCube + 0.2309699292 * sCube;
|
||||
const green = -1.2684380046 * lCube + 2.6097574011 * mCube - 0.3413193965 * sCube;
|
||||
const blue = -0.0041960863 * lCube - 0.7034186147 * mCube + 1.707614701 * sCube;
|
||||
|
||||
return [linearToSrgb(red) * 255, linearToSrgb(green) * 255, linearToSrgb(blue) * 255];
|
||||
}
|
||||
|
||||
function srgbToLinear(value: number): number {
|
||||
return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
function linearToSrgb(value: number): number {
|
||||
const srgb = value <= 0.0031308 ? value * 12.92 : 1.055 * Math.pow(value, 1 / 2.4) - 0.055;
|
||||
return clamp(srgb, 0, 1);
|
||||
}
|
||||
|
||||
function normalizeHue(value: number): number {
|
||||
const hue = value % 360;
|
||||
return hue < 0 ? hue + 360 : hue;
|
||||
}
|
||||
|
||||
function degToRad(value: number): number {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function radToDeg(value: number): number {
|
||||
return (value * 180) / Math.PI;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function rgbaToHex(r: number, g: number, b: number, a: number): string {
|
||||
const toHex = (n: number): string => {
|
||||
const hex = Number(Math.round(n)).toString(16);
|
||||
|
||||
@@ -364,6 +364,8 @@ function TreeItem_<T extends { id: string }>({
|
||||
ref={handleEditFocus}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className="bg-transparent outline-none w-full cursor-text"
|
||||
onBlur={handleEditBlur}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
|
||||
@@ -6,7 +6,8 @@ const Downloader = require("nodejs-file-downloader");
|
||||
const { rmSync, cpSync, mkdirSync, existsSync } = require("node:fs");
|
||||
const { execSync } = require("node:child_process");
|
||||
|
||||
const NODE_VERSION = "v24.11.1";
|
||||
const nodeVersionFile = path.join(__dirname, "..", "packages", "plugin-runtime", ".node-version");
|
||||
const NODE_VERSION = `v${fs.readFileSync(nodeVersionFile, "utf8").trim().replace(/^v/, "")}`;
|
||||
|
||||
// `${process.platform}_${process.arch}`
|
||||
const MAC_ARM = "darwin_arm64";
|
||||
|
||||
Reference in New Issue
Block a user