mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-02 00:27:16 +02:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50cccf1d25 | ||
|
|
0aae516f3a | ||
|
|
b7b8ae5f94 | ||
|
|
6777003b70 | ||
|
|
426c6d5eb6 | ||
|
|
068afe325e | ||
|
|
cef1129d4b | ||
|
|
9a7bcf73bb | ||
|
|
aa76d501f0 | ||
|
|
ce8cb5e7bc |
Generated
+3
-1
@@ -11723,7 +11723,10 @@ name = "yaak-system-appearance"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dark-light",
|
||||
"dispatch2",
|
||||
"log 0.4.29",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation 0.3.1",
|
||||
"tauri",
|
||||
]
|
||||
|
||||
@@ -11775,7 +11778,6 @@ dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
"log 0.4.29",
|
||||
"md5 0.7.0",
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
|
||||
@@ -67,7 +67,14 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
|
||||
)}
|
||||
// If no environments, the button simply opens the dialog.
|
||||
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
|
||||
onClick={subEnvironments.length === 0 ? () => editEnvironment(null) : undefined}
|
||||
onClick={
|
||||
subEnvironments.length === 0
|
||||
? (event) => {
|
||||
event.preventDefault();
|
||||
editEnvironment(null);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
{...buttonProps}
|
||||
>
|
||||
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
|
||||
|
||||
@@ -2,7 +2,9 @@ import type {
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
HttpVersion,
|
||||
InheritedBoolSetting,
|
||||
InheritedHttpVersionSetting,
|
||||
InheritedIntSetting,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
modelSupportsSetting,
|
||||
type RequestSettingDefinition,
|
||||
SETTING_FOLLOW_REDIRECTS,
|
||||
SETTING_HTTP_VERSION,
|
||||
SETTING_REQUEST_MESSAGE_SIZE,
|
||||
SETTING_REQUEST_TIMEOUT,
|
||||
SETTING_SEND_COOKIES,
|
||||
@@ -21,6 +24,7 @@ import {
|
||||
} from "../lib/requestSettings";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { Select } from "./core/Select";
|
||||
import {
|
||||
SettingOverrideRow,
|
||||
SettingRow,
|
||||
@@ -38,37 +42,21 @@ interface Props {
|
||||
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 ModelWithMessageSizeSettings =
|
||||
| Workspace
|
||||
| Folder
|
||||
| WebsocketRequest
|
||||
| GrpcRequest;
|
||||
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 HttpVersionSetting = HttpVersion | InheritedHttpVersionSetting;
|
||||
type CookieSettingsPatch = {
|
||||
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
|
||||
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
|
||||
};
|
||||
type HttpSettingsPatch = {
|
||||
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
|
||||
settingHttpVersion?: ModelWithHttpSettings["settingHttpVersion"];
|
||||
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
|
||||
};
|
||||
type TlsSettingsPatch = {
|
||||
@@ -78,10 +66,7 @@ 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);
|
||||
@@ -154,12 +139,26 @@ export function ModelSettingsEditor({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{supportsHttpSettings && (
|
||||
<HttpVersionSettingRow
|
||||
settingDefinition={SETTING_HTTP_VERSION}
|
||||
setting={model.settingHttpVersion}
|
||||
inheritedValue={resolveInheritedValue(
|
||||
ancestors,
|
||||
SETTING_HTTP_VERSION.modelKey,
|
||||
model.settingHttpVersion,
|
||||
)}
|
||||
onChange={(settingHttpVersion) =>
|
||||
patchHttpSettings(model, {
|
||||
settingHttpVersion,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
{supportsCookieSettings && (
|
||||
<SettingsSection
|
||||
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
|
||||
>
|
||||
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
|
||||
<BooleanSettingRow
|
||||
settingDefinition={SETTING_SEND_COOKIES}
|
||||
setting={model.settingSendCookies}
|
||||
@@ -195,7 +194,7 @@ export function ModelSettingsEditor({
|
||||
}
|
||||
|
||||
export function countOverriddenSettings(model: ModelWithSettings) {
|
||||
const settings: (BooleanSetting | IntegerSetting)[] = [];
|
||||
const settings: (BooleanSetting | IntegerSetting | HttpVersionSetting)[] = [];
|
||||
|
||||
if (modelSupportsCookieSettings(model)) {
|
||||
settings.push(model.settingSendCookies, model.settingStoreCookies);
|
||||
@@ -204,22 +203,22 @@ export function countOverriddenSettings(model: ModelWithSettings) {
|
||||
settings.push(model.settingValidateCertificates);
|
||||
|
||||
if (modelSupportsHttpSettings(model)) {
|
||||
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
|
||||
settings.push(
|
||||
model.settingFollowRedirects,
|
||||
model.settingRequestTimeout,
|
||||
model.settingHttpVersion,
|
||||
);
|
||||
}
|
||||
|
||||
if (modelSupportsMessageSizeSettings(model)) {
|
||||
settings.push(model.settingRequestMessageSize);
|
||||
}
|
||||
|
||||
return settings.filter(
|
||||
(setting) => isInheritedSetting(setting) && setting.enabled === true,
|
||||
).length;
|
||||
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
|
||||
.length;
|
||||
}
|
||||
|
||||
function patchCookieSettings(
|
||||
model: ModelWithCookieSettings,
|
||||
patch: Partial<CookieSettingsPatch>,
|
||||
) {
|
||||
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
|
||||
switch (model.model) {
|
||||
case "workspace":
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
@@ -232,10 +231,7 @@ function patchCookieSettings(
|
||||
}
|
||||
}
|
||||
|
||||
function patchHttpSettings(
|
||||
model: ModelWithHttpSettings,
|
||||
patch: Partial<HttpSettingsPatch>,
|
||||
) {
|
||||
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
|
||||
switch (model.model) {
|
||||
case "workspace":
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
@@ -246,10 +242,7 @@ function patchHttpSettings(
|
||||
}
|
||||
}
|
||||
|
||||
function patchTlsSettings(
|
||||
model: ModelWithTlsSettings,
|
||||
patch: Partial<TlsSettingsPatch>,
|
||||
) {
|
||||
function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
|
||||
switch (model.model) {
|
||||
case "workspace":
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
@@ -280,21 +273,15 @@ function patchMessageSizeSettings(
|
||||
}
|
||||
}
|
||||
|
||||
function modelSupportsHttpSettings(
|
||||
model: ModelWithSettings,
|
||||
): model is ModelWithHttpSettings {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -317,11 +304,7 @@ 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 (
|
||||
@@ -352,6 +335,63 @@ function BooleanSettingRow({
|
||||
);
|
||||
}
|
||||
|
||||
const HTTP_VERSION_OPTIONS: { label: string; value: HttpVersion }[] = [
|
||||
{ label: "Automatic", value: "auto" },
|
||||
{ label: "HTTP/1.1", value: "http1" },
|
||||
{ label: "HTTP/2", value: "http2" },
|
||||
];
|
||||
|
||||
function HttpVersionSettingRow({
|
||||
inheritedValue,
|
||||
setting,
|
||||
settingDefinition,
|
||||
onChange,
|
||||
}: {
|
||||
inheritedValue: HttpVersion;
|
||||
setting: HttpVersionSetting;
|
||||
settingDefinition: RequestSettingDefinition<"settingHttpVersion">;
|
||||
onChange: (setting: HttpVersionSetting) => void;
|
||||
}) {
|
||||
const inherited = isInheritedSetting(setting);
|
||||
const overridden = inherited ? setting.enabled === true : false;
|
||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||
|
||||
if (!inherited) {
|
||||
return (
|
||||
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
||||
<Select
|
||||
hideLabel
|
||||
name={settingDefinition.modelKey}
|
||||
label={settingDefinition.title}
|
||||
size="sm"
|
||||
value={value}
|
||||
options={HTTP_VERSION_OPTIONS}
|
||||
onChange={(value) => onChange(value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingOverrideRow
|
||||
title={settingDefinition.title}
|
||||
description={settingDefinition.description}
|
||||
overridden={overridden}
|
||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
||||
>
|
||||
<Select
|
||||
hideLabel
|
||||
name={settingDefinition.modelKey}
|
||||
label={settingDefinition.title}
|
||||
size="sm"
|
||||
value={value}
|
||||
options={HTTP_VERSION_OPTIONS}
|
||||
onChange={(value) => onChange({ ...setting, enabled: true, value })}
|
||||
/>
|
||||
</SettingOverrideRow>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegerSettingRow({
|
||||
inheritedValue,
|
||||
setting,
|
||||
@@ -365,18 +405,11 @@ function IntegerSettingRow({
|
||||
}) {
|
||||
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 (
|
||||
<SettingRow
|
||||
title={settingDefinition.title}
|
||||
description={settingDefinition.description}
|
||||
>
|
||||
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
||||
<NumberUnitInput
|
||||
name={settingDefinition.modelKey}
|
||||
label={settingDefinition.title}
|
||||
@@ -429,20 +462,13 @@ function MessageSizeSettingRow({
|
||||
}) {
|
||||
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;
|
||||
const displayValue = formatMegabytes(value);
|
||||
const placeholder = "0";
|
||||
|
||||
if (!inherited) {
|
||||
return (
|
||||
<SettingRow
|
||||
title={settingDefinition.title}
|
||||
description={settingDefinition.description}
|
||||
>
|
||||
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
||||
<MessageSizeInput
|
||||
name={settingDefinition.modelKey}
|
||||
label={settingDefinition.title}
|
||||
@@ -567,13 +593,18 @@ function resolveInheritedValue(
|
||||
key: BooleanWorkspaceSettingKey,
|
||||
fallback: BooleanSetting,
|
||||
): boolean;
|
||||
function resolveInheritedValue(
|
||||
ancestors: (Folder | Workspace)[],
|
||||
key: "settingHttpVersion",
|
||||
fallback: HttpVersionSetting,
|
||||
): HttpVersion;
|
||||
function resolveInheritedValue(
|
||||
ancestors: (Folder | Workspace)[],
|
||||
key: keyof WorkspaceSettings,
|
||||
fallback: BooleanSetting | IntegerSetting,
|
||||
fallback: BooleanSetting | IntegerSetting | HttpVersionSetting,
|
||||
) {
|
||||
for (const ancestor of ancestors) {
|
||||
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
|
||||
const setting = ancestor[key] as BooleanSetting | IntegerSetting | HttpVersionSetting;
|
||||
if (isInheritedSetting(setting)) {
|
||||
if (setting.enabled === true) {
|
||||
return setting.value;
|
||||
@@ -589,6 +620,7 @@ function resolveInheritedValue(
|
||||
type WorkspaceSettings = Pick<
|
||||
Workspace,
|
||||
| "settingFollowRedirects"
|
||||
| "settingHttpVersion"
|
||||
| "settingRequestMessageSize"
|
||||
| "settingRequestTimeout"
|
||||
| "settingSendCookies"
|
||||
@@ -598,14 +630,12 @@ type WorkspaceSettings = Pick<
|
||||
|
||||
type BooleanWorkspaceSettingKey = Exclude<
|
||||
keyof WorkspaceSettings,
|
||||
"settingRequestTimeout" | "settingRequestMessageSize"
|
||||
"settingRequestTimeout" | "settingRequestMessageSize" | "settingHttpVersion"
|
||||
>;
|
||||
|
||||
function formatMegabytes(bytes: number) {
|
||||
const megabytes = bytes / BYTES_PER_MB;
|
||||
return Number.isInteger(megabytes)
|
||||
? `${megabytes}`
|
||||
: megabytes.toFixed(3).replace(/\.?0+$/, "");
|
||||
return Number.isInteger(megabytes) ? `${megabytes}` : megabytes.toFixed(3).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function parseMegabytes(value: string) {
|
||||
@@ -626,9 +656,5 @@ function isValidInteger(value: string) {
|
||||
function isValidMegabytes(value: string) {
|
||||
if (value === "") return true;
|
||||
const megabytes = Number(value);
|
||||
return (
|
||||
Number.isFinite(megabytes) &&
|
||||
megabytes >= 0 &&
|
||||
megabytes <= MAX_MESSAGE_SIZE_MB
|
||||
);
|
||||
return Number.isFinite(megabytes) && megabytes >= 0 && megabytes <= MAX_MESSAGE_SIZE_MB;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
UpdateResponse,
|
||||
YaakNotification,
|
||||
} from "@yaakapp-internal/tauri-client";
|
||||
import { HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { openSettings } from "../commands/openSettings";
|
||||
import { Button } from "../components/core/Button";
|
||||
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
|
||||
@@ -180,9 +180,65 @@ function showUpdateInstalledToast(version: string) {
|
||||
|
||||
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
|
||||
const UPDATE_TOAST_ID = "update-info";
|
||||
const { version, replyEventId, downloaded } = updateInfo;
|
||||
const { version, replyEventId, downloaded, install } = updateInfo;
|
||||
|
||||
jotaiStore.set(updateAvailableAtom, { version, downloaded });
|
||||
jotaiStore.set(updateAvailableAtom, { version, downloaded, install });
|
||||
|
||||
const whatsNewButton = (
|
||||
<Button
|
||||
size="xs"
|
||||
color="info"
|
||||
variant="border"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={async () => {
|
||||
await platform.openUrl(`https://yaak.app/changelog/${version}`);
|
||||
}}
|
||||
>
|
||||
What's New
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (install !== "integrated") {
|
||||
// Nothing to reply to here; the backend only told us so we can say how to update
|
||||
const flatpak = install === "flatpak";
|
||||
showToast({
|
||||
id: UPDATE_TOAST_ID,
|
||||
color: "info",
|
||||
timeout: null,
|
||||
message: (
|
||||
<VStack>
|
||||
<h2 className="font-semibold">Yaak {version} is available</h2>
|
||||
<p className="text-text-subtle text-sm">
|
||||
{flatpak ? (
|
||||
<>
|
||||
Update with <InlineCode>flatpak update</InlineCode> or your software center.
|
||||
</>
|
||||
) : (
|
||||
"Download the new version to upgrade."
|
||||
)}
|
||||
</p>
|
||||
</VStack>
|
||||
),
|
||||
action: () => (
|
||||
<HStack space={1.5}>
|
||||
{!flatpak && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="info"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={async () => {
|
||||
await platform.openUrl("https://yaak.app/download");
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
{whatsNewButton}
|
||||
</HStack>
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Acknowledge the event, so we don't time out and try the fallback update logic
|
||||
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
|
||||
@@ -215,17 +271,7 @@ async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
|
||||
>
|
||||
{downloaded ? "Install Now" : "Download and Install"}
|
||||
</ButtonInfiniteLoading>
|
||||
<Button
|
||||
size="xs"
|
||||
color="info"
|
||||
variant="border"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={async () => {
|
||||
await platform.openUrl(`https://yaak.app/changelog/${version}`);
|
||||
}}
|
||||
>
|
||||
What's New
|
||||
</Button>
|
||||
{whatsNewButton}
|
||||
</HStack>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ type ModelType = AnyModel["model"];
|
||||
type WorkspaceRequestSettings = Pick<
|
||||
Workspace,
|
||||
| "settingFollowRedirects"
|
||||
| "settingHttpVersion"
|
||||
| "settingRequestMessageSize"
|
||||
| "settingRequestTimeout"
|
||||
| "settingSendCookies"
|
||||
@@ -18,9 +19,7 @@ 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;
|
||||
@@ -46,8 +45,7 @@ export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
|
||||
|
||||
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.",
|
||||
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",
|
||||
@@ -57,13 +55,7 @@ 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",
|
||||
});
|
||||
|
||||
@@ -75,10 +67,17 @@ export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
|
||||
title: "Follow redirects",
|
||||
});
|
||||
|
||||
export const SETTING_HTTP_VERSION = defineRequestSetting({
|
||||
defaultValue: "auto",
|
||||
description: "Force HTTP/1.1 or HTTP/2 for servers that don't negotiate the version correctly.",
|
||||
modelKey: "settingHttpVersion",
|
||||
models: ["workspace", "folder", "http_request"],
|
||||
title: "HTTP version",
|
||||
});
|
||||
|
||||
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",
|
||||
@@ -86,8 +85,7 @@ 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",
|
||||
|
||||
+111
-24
@@ -1,48 +1,135 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, };
|
||||
export type Cookie = {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: CookieDomain;
|
||||
expires: CookieExpires;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: CookieSameSite | null;
|
||||
};
|
||||
|
||||
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
|
||||
export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
|
||||
|
||||
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
|
||||
export type CookieExpires = { AtUtc: string } | "SessionEnd";
|
||||
|
||||
export type CookieSameSite = "Strict" | "Lax" | "None";
|
||||
|
||||
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string,
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, };
|
||||
export type HttpRequest = {
|
||||
model: "http_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
body: Record<string, any>;
|
||||
bodyType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
method: string;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
url: string;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
|
||||
/**
|
||||
* Serializable representation of HTTP response events for DB storage.
|
||||
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
||||
*/
|
||||
export type HttpResponseEventData = { "type": "setting", name: string, value: string, source_model?: string, source_id?: string, source_name?: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
|
||||
export type HttpResponseEventData =
|
||||
| {
|
||||
type: "setting";
|
||||
name: string;
|
||||
value: string;
|
||||
source_model?: string;
|
||||
source_id?: string;
|
||||
source_name?: string;
|
||||
}
|
||||
| { type: "info"; message: string }
|
||||
| {
|
||||
type: "redirect";
|
||||
url: string;
|
||||
status: number;
|
||||
behavior: string;
|
||||
dropped_body: boolean;
|
||||
dropped_headers: Array<string>;
|
||||
}
|
||||
| {
|
||||
type: "send_url";
|
||||
method: string;
|
||||
scheme: string;
|
||||
username: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
path: string;
|
||||
query: string;
|
||||
fragment: string;
|
||||
}
|
||||
| { type: "receive_url"; version: string; status: string }
|
||||
| { type: "header_up"; name: string; value: string }
|
||||
| { type: "header_down"; name: string; value: string }
|
||||
| { type: "chunk_sent"; bytes: number }
|
||||
| { type: "chunk_received"; bytes: number }
|
||||
| {
|
||||
type: "dns_resolved";
|
||||
hostname: string;
|
||||
addresses: Array<string>;
|
||||
duration: bigint;
|
||||
overridden: boolean;
|
||||
};
|
||||
|
||||
export type HttpResponseHeader = { name: string, value: string, };
|
||||
export type HttpResponseHeader = { name: string; value: string };
|
||||
|
||||
/**
|
||||
* The resolved send settings, values only: what an executor has to obey, with the sources
|
||||
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
|
||||
* crosses from a tab to the Yaak server, and what the server reads.
|
||||
*/
|
||||
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
|
||||
/**
|
||||
* Milliseconds. Zero or negative means no timeout.
|
||||
*/
|
||||
timeoutMs: number, sendCookies: boolean, storeCookies: boolean, };
|
||||
export type HttpSendSettings = {
|
||||
validateCertificates: boolean;
|
||||
followRedirects: boolean;
|
||||
/**
|
||||
* Milliseconds. Zero or negative means no timeout.
|
||||
*/
|
||||
timeoutMs: number;
|
||||
sendCookies: boolean;
|
||||
storeCookies: boolean;
|
||||
httpVersion: HttpVersion;
|
||||
};
|
||||
|
||||
export type HttpUrlParameter = { enabled?: boolean,
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string, value: string, id?: string, };
|
||||
export type HttpUrlParameter = {
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string;
|
||||
value: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean, value: number, };
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||
|
||||
@@ -157,6 +157,7 @@ impl PreparedSend {
|
||||
let (client, resolver) = HttpConnectionOptions {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
validate_certificates: self.settings.validate_certificates,
|
||||
http_version: self.settings.http_version,
|
||||
// The proxy connects directly. Going through a system proxy would move DNS, and
|
||||
// therefore the address check, somewhere this process can't see.
|
||||
proxy: HttpConnectionProxySetting::Disabled,
|
||||
|
||||
+28
-6
@@ -1,15 +1,37 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PluginUpdateInfo = { name: string, currentVersion: string, latestVersion: string, };
|
||||
export type PluginUpdateInfo = { name: string; currentVersion: string; latestVersion: string };
|
||||
|
||||
export type PluginUpdateNotification = { updateCount: number, plugins: Array<PluginUpdateInfo>, };
|
||||
export type PluginUpdateNotification = { updateCount: number; plugins: Array<PluginUpdateInfo> };
|
||||
|
||||
export type UpdateInfo = { replyEventId: string, version: string, downloaded: boolean, };
|
||||
export type UpdateInfo = {
|
||||
replyEventId: string;
|
||||
version: string;
|
||||
downloaded: boolean;
|
||||
/**
|
||||
* How this update gets applied. Anything but `Integrated` means the app can't do it
|
||||
* itself and the user is told how to update instead.
|
||||
*/
|
||||
install: UpdateInstall;
|
||||
};
|
||||
|
||||
export type UpdateResponse = { "type": "ack" } | { "type": "action", action: UpdateResponseAction, };
|
||||
/**
|
||||
* How an update can be applied to this install.
|
||||
*/
|
||||
export type UpdateInstall = "integrated" | "flatpak" | "manual";
|
||||
|
||||
export type UpdateResponse = { type: "ack" } | { type: "action"; action: UpdateResponseAction };
|
||||
|
||||
export type UpdateResponseAction = "install" | "skip";
|
||||
|
||||
export type YaakNotification = { timestamp: string, timeout: number | null, id: string, title: string | null, message: string, color: string | null, action: YaakNotificationAction | null, };
|
||||
export type YaakNotification = {
|
||||
timestamp: string;
|
||||
timeout: number | null;
|
||||
id: string;
|
||||
title: string | null;
|
||||
message: string;
|
||||
color: string | null;
|
||||
action: YaakNotificationAction | null;
|
||||
};
|
||||
|
||||
export type YaakNotificationAction = { label: string, url: string, };
|
||||
export type YaakNotificationAction = { label: string; url: string };
|
||||
|
||||
@@ -1367,6 +1367,16 @@ pub fn run() {
|
||||
debug!("Launched Yaak {:?}", info);
|
||||
});
|
||||
}
|
||||
RunEvent::WindowEvent { event: WindowEvent::ThemeChanged(_), .. } => {
|
||||
// On macOS this is how OS appearance changes arrive: tao observes
|
||||
// AppleInterfaceThemeChangedNotification and emits it for every window
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
if let Some(state) =
|
||||
app_handle.try_state::<yaak_system_appearance::SystemAppearanceState>()
|
||||
{
|
||||
yaak_system_appearance::emit_change(app_handle, &state);
|
||||
}
|
||||
}
|
||||
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
if let Some(state) =
|
||||
|
||||
@@ -76,14 +76,6 @@ impl YaakUpdater {
|
||||
auto_download: bool,
|
||||
update_trigger: UpdateTrigger,
|
||||
) -> Result<bool> {
|
||||
// Only AppImage supports updates on Linux, so skip if it's not
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if std::env::var("APPIMAGE").is_err() {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
let settings = window.db().get_settings();
|
||||
let update_key = format!("{:x}", md5::compute(settings.id));
|
||||
self.last_check = Some(Instant::now());
|
||||
@@ -130,6 +122,18 @@ impl YaakUpdater {
|
||||
Some(update) => {
|
||||
let w = window.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Only hand the artifact to the updater plugin when this install can
|
||||
// apply it itself; otherwise tell the user how to update instead
|
||||
let install = update_install_method(&update);
|
||||
if install != UpdateInstall::Integrated {
|
||||
info!(
|
||||
"{} available, but this install updates via {install:?}",
|
||||
update.version
|
||||
);
|
||||
notify_external_update(&w, &update, install);
|
||||
return;
|
||||
}
|
||||
|
||||
// Force native updater if specified (useful if a release broke the UI)
|
||||
let native_install_mode =
|
||||
update.raw_json.get("install_mode").map(|v| v.as_str()).unwrap_or_default()
|
||||
@@ -207,6 +211,23 @@ struct UpdateInfo {
|
||||
reply_event_id: String,
|
||||
version: String,
|
||||
downloaded: bool,
|
||||
/// How this update gets applied. Anything but `Integrated` means the app can't do it
|
||||
/// itself and the user is told how to update instead.
|
||||
install: UpdateInstall,
|
||||
}
|
||||
|
||||
/// How an update can be applied to this install.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Default, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
enum UpdateInstall {
|
||||
/// The app downloads and installs it itself
|
||||
#[default]
|
||||
Integrated,
|
||||
/// Flatpak install: updated by `flatpak update` from its remote (e.g. FlatPark)
|
||||
Flatpak,
|
||||
/// Nothing can install it in-app (distro package, Nix, unknown); download by hand
|
||||
Manual,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, TS)]
|
||||
@@ -272,8 +293,12 @@ async fn start_integrated_update<R: Runtime>(
|
||||
let _guard = Unlisten { win: window, id: event_id };
|
||||
|
||||
// 2) Emit the event now that listener is in place
|
||||
let info =
|
||||
UpdateInfo { version: update.version.to_string(), downloaded, reply_event_id: reply_id };
|
||||
let info = UpdateInfo {
|
||||
version: update.version.to_string(),
|
||||
downloaded,
|
||||
install: UpdateInstall::Integrated,
|
||||
reply_event_id: reply_id,
|
||||
};
|
||||
window
|
||||
.emit_to(window.label(), "update_available", &info)
|
||||
.map_err(|e| GenericError(format!("Failed to emit update_available: {e}")))?;
|
||||
@@ -306,6 +331,24 @@ async fn start_integrated_update<R: Runtime>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the frontend about an update this install can't apply itself, so the user can be
|
||||
/// told how to get it. Unlike the integrated flow, there is nothing to reply to.
|
||||
fn notify_external_update<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
update: &Update,
|
||||
install: UpdateInstall,
|
||||
) {
|
||||
let info = UpdateInfo {
|
||||
version: update.version.to_string(),
|
||||
downloaded: false,
|
||||
install,
|
||||
reply_event_id: generate_id(),
|
||||
};
|
||||
if let Err(e) = window.emit_to(window.label(), "update_available", &info) {
|
||||
warn!("Failed to emit update_available: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_native_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {
|
||||
// If the frontend doesn't respond, fallback to native dialogs
|
||||
let confirmed = window
|
||||
@@ -376,7 +419,137 @@ fn detect_install_mode() -> Option<&'static str> {
|
||||
return Some("nsis");
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
None
|
||||
if !cfg!(target_os = "linux") {
|
||||
None
|
||||
} else if is_flatpak() {
|
||||
Some("flatpak")
|
||||
} else {
|
||||
linux_installer()
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatpak installs (e.g. FlatPark) are updated by flatpak from their remote; the in-app
|
||||
/// updater can't write inside the sandbox and must not try.
|
||||
fn is_flatpak() -> bool {
|
||||
std::env::var_os("FLATPAK_ID").is_some()
|
||||
}
|
||||
|
||||
/// How Yaak was installed on Linux, as far as the updater plugin can install into it.
|
||||
///
|
||||
/// The bundle type is stamped into the binary by the bundler, but that only says how the
|
||||
/// binary was *packaged*: third-party packages (AUR, Nix, ...) repackage the .deb, and
|
||||
/// letting dpkg/rpm replace those would stomp on another package manager's files. So a
|
||||
/// deb/rpm install also has to be one the package manager actually owns. The AppImage
|
||||
/// updater needs `$APPIMAGE` since that's the file it replaces.
|
||||
fn linux_installer() -> Option<&'static str> {
|
||||
use tauri::utils::{config::BundleType, platform::bundle_type};
|
||||
match bundle_type() {
|
||||
Some(BundleType::Deb) if package_manager_owns_exe("dpkg", "-S") => Some("deb"),
|
||||
Some(BundleType::Rpm) if package_manager_owns_exe("rpm", "-qf") => Some("rpm"),
|
||||
Some(BundleType::Deb) | Some(BundleType::Rpm) => None,
|
||||
_ if std::env::var_os("APPIMAGE").is_some() => Some("appimage"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `cmd query_arg <current exe>` succeeds, i.e. that package manager knows the
|
||||
/// running executable as one of its files. False when the tool isn't installed at all.
|
||||
fn package_manager_owns_exe(cmd: &str, query_arg: &str) -> bool {
|
||||
let Ok(exe) = std::env::current_exe() else {
|
||||
return false;
|
||||
};
|
||||
std::process::Command::new(cmd)
|
||||
.arg(query_arg)
|
||||
.arg(&exe)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// How the artifact the server returned can be applied to this install. On Linux the
|
||||
/// server may hand back a different package format than the one installed, Flatpak can't
|
||||
/// be written from inside the sandbox, and unknown install methods (distro packages,
|
||||
/// Nix, ...) can't be updated in-app at all.
|
||||
fn update_install_method(update: &Update) -> UpdateInstall {
|
||||
// Dev-only override to preview the non-integrated flows on any OS:
|
||||
// YAAK_SIMULATE_INSTALL=flatpak|manual
|
||||
if is_dev() {
|
||||
match std::env::var("YAAK_SIMULATE_INSTALL").as_deref() {
|
||||
Ok("flatpak") => return UpdateInstall::Flatpak,
|
||||
Ok("manual") => return UpdateInstall::Manual,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !cfg!(target_os = "linux") {
|
||||
return UpdateInstall::Integrated;
|
||||
}
|
||||
if is_flatpak() {
|
||||
return UpdateInstall::Flatpak;
|
||||
}
|
||||
if artifact_matches_installer(linux_installer(), update.download_url.path()) {
|
||||
UpdateInstall::Integrated
|
||||
} else {
|
||||
UpdateInstall::Manual
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the artifact at `url_path` is in the package format `installer` can install.
|
||||
fn artifact_matches_installer(installer: Option<&str>, url_path: &str) -> bool {
|
||||
let path = url_path.to_ascii_lowercase();
|
||||
match installer {
|
||||
Some("deb") => path.ends_with(".deb"),
|
||||
Some("rpm") => path.ends_with(".rpm"),
|
||||
Some("appimage") => path.ends_with(".appimage") || path.ends_with(".appimage.tar.gz"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::artifact_matches_installer;
|
||||
|
||||
const BASE: &str = "/mountain-loop/yaak/releases/download/v2026.6.0/";
|
||||
|
||||
#[test]
|
||||
fn matching_package_format_is_installable() {
|
||||
let cases = [
|
||||
("deb", "yaak_2026.6.0_amd64.deb"),
|
||||
("deb", "yaak_2026.6.0_arm64.deb"),
|
||||
("rpm", "yaak-2026.6.0-1.x86_64.rpm"),
|
||||
("rpm", "yaak-2026.6.0-1.aarch64.rpm"),
|
||||
("appimage", "yaak_2026.6.0_amd64.AppImage"),
|
||||
("appimage", "yaak_2026.6.0_amd64.AppImage.tar.gz"),
|
||||
];
|
||||
for (installer, asset) in cases {
|
||||
assert!(
|
||||
artifact_matches_installer(Some(installer), &format!("{BASE}{asset}")),
|
||||
"{installer} should install {asset}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_package_format_is_not_installable() {
|
||||
// What the server returns for every Linux install today
|
||||
let appimage = format!("{BASE}yaak_2026.6.0_amd64.AppImage");
|
||||
assert!(!artifact_matches_installer(Some("deb"), &appimage));
|
||||
assert!(!artifact_matches_installer(Some("rpm"), &appimage));
|
||||
|
||||
let deb = format!("{BASE}yaak_2026.6.0_amd64.deb");
|
||||
assert!(!artifact_matches_installer(Some("rpm"), &deb));
|
||||
assert!(!artifact_matches_installer(Some("appimage"), &deb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_installer_is_never_installable() {
|
||||
for asset in ["yaak_2026.6.0_amd64.deb", "yaak_2026.6.0_amd64.AppImage"] {
|
||||
assert!(!artifact_matches_installer(None, &format!("{BASE}{asset}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn install_update_maybe_download<R: Runtime>(
|
||||
|
||||
@@ -4,9 +4,14 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
dark-light = "2.0.0"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
dispatch2 = "0.3.0"
|
||||
objc2-app-kit = { version = "0.3.1", features = ["NSAppearance", "NSApplication", "NSResponder"] }
|
||||
objc2-foundation = { version = "0.3.1", features = ["NSArray", "NSString", "NSUserDefaults"] }
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
tauri = { workspace = true }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
@@ -11,7 +11,7 @@ use tauri::{AppHandle, Runtime};
|
||||
pub const INITIAL_APPEARANCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__";
|
||||
pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change";
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[cfg(target_os = "linux")]
|
||||
const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -47,14 +47,12 @@ pub fn initialization_script(appearance: Appearance) -> String {
|
||||
|
||||
/// Detect the appearance the OS prefers, independent of any appearance that has
|
||||
/// been forced onto app windows (which is what the webview itself reports).
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn system_appearance() -> Option<Appearance> {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(appearance) = gsettings_system_appearance() {
|
||||
return Some(appearance);
|
||||
}
|
||||
|
||||
// On macOS this reads AppleInterfaceStyle from the global user defaults
|
||||
match dark_light::detect() {
|
||||
Ok(dark_light::Mode::Dark) => Some(Appearance::Dark),
|
||||
Ok(dark_light::Mode::Light) => Some(Appearance::Light),
|
||||
@@ -66,11 +64,69 @@ pub fn system_appearance() -> Option<Appearance> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the appearance the OS prefers, independent of any appearance that has
|
||||
/// been forced onto app windows (which is what the webview itself reports).
|
||||
///
|
||||
/// This asks AppKit for the application's effective appearance, the same source tauri
|
||||
/// uses for `window.theme()`, instead of reading `AppleInterfaceStyle` from the user
|
||||
/// defaults: macOS 27 no longer reliably writes that key when dark mode is on, so anything
|
||||
/// reading it sees light mode. Appearances forced per window (yaak-mac-window) don't reach
|
||||
/// `NSApp`, so this is the OS preference.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn system_appearance() -> Option<Appearance> {
|
||||
use objc2_app_kit::{NSAppearanceNameAqua, NSAppearanceNameDarkAqua, NSApplication};
|
||||
use objc2_foundation::NSArray;
|
||||
|
||||
// AppKit is main-thread only. Every caller runs there today; this keeps it correct if
|
||||
// one ever doesn't.
|
||||
dispatch2::run_on_main(|mtm| {
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
|
||||
// An appearance forced on the whole app (tauri's `set_theme` does this) would make
|
||||
// the effective appearance report the override instead of the OS preference. Nothing
|
||||
// in Yaak does that, but fall back to the user defaults if something ever does.
|
||||
//
|
||||
// SAFETY: Called on the main thread with the shared application
|
||||
if unsafe { app.appearance() }.is_some() {
|
||||
return defaults_appearance();
|
||||
}
|
||||
|
||||
// SAFETY: The appearance names are AppKit constants that live for the whole process
|
||||
let (dark, light) = unsafe { (NSAppearanceNameDarkAqua, NSAppearanceNameAqua) };
|
||||
let names = NSArray::from_slice(&[dark, light]);
|
||||
let best = app.effectiveAppearance().bestMatchFromAppearancesWithNames(&names)?;
|
||||
|
||||
// SAFETY: Both are valid strings
|
||||
let is_dark = unsafe { best.isEqualToString(dark) };
|
||||
Some(if is_dark { Appearance::Dark } else { Appearance::Light })
|
||||
})
|
||||
}
|
||||
|
||||
/// The appearance macOS persists to the global user defaults. Absent means light, except
|
||||
/// on macOS 27, which stopped reliably writing the key. Only used when the effective
|
||||
/// appearance is forced and can't be trusted.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn defaults_appearance() -> Option<Appearance> {
|
||||
use objc2_foundation::{NSUserDefaults, ns_string};
|
||||
|
||||
// SAFETY: The standard defaults are a process-wide singleton and the key is a valid string
|
||||
let style = unsafe {
|
||||
NSUserDefaults::standardUserDefaults().stringForKey(ns_string!("AppleInterfaceStyle"))
|
||||
};
|
||||
|
||||
// SAFETY: Both are valid strings
|
||||
let is_dark = style.is_some_and(|style| unsafe { style.isEqualToString(ns_string!("Dark")) });
|
||||
Some(if is_dark { Appearance::Dark } else { Appearance::Light })
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
pub fn system_appearance() -> Option<Appearance> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Start tracking the OS appearance. Linux polls for changes. macOS gets them from tauri's
|
||||
/// `WindowEvent::ThemeChanged` (tao observes `AppleInterfaceThemeChangedNotification`), which
|
||||
/// the app forwards to [`emit_change`], so no thread is needed there.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
pub fn watch<R: Runtime>(app_handle: AppHandle<R>) -> Option<SystemAppearanceState> {
|
||||
let last_appearance = system_appearance();
|
||||
@@ -80,13 +136,19 @@ pub fn watch<R: Runtime>(app_handle: AppHandle<R>) -> Option<SystemAppearanceSta
|
||||
}
|
||||
|
||||
let state = SystemAppearanceState { last_appearance: Arc::new(Mutex::new(last_appearance)) };
|
||||
let thread_state = state.clone();
|
||||
let _ = std::thread::spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(SYSTEM_APPEARANCE_POLL_INTERVAL);
|
||||
emit_change(&app_handle, &thread_state);
|
||||
}
|
||||
});
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let thread_state = state.clone();
|
||||
let _ = std::thread::spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(SYSTEM_APPEARANCE_POLL_INTERVAL);
|
||||
emit_change(&app_handle, &thread_state);
|
||||
}
|
||||
});
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = app_handle;
|
||||
|
||||
Some(state)
|
||||
}
|
||||
|
||||
+452
-70
@@ -1,127 +1,509 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
|
||||
export type AnyModel =
|
||||
| CookieJar
|
||||
| Environment
|
||||
| Folder
|
||||
| GraphQlIntrospection
|
||||
| GrpcConnection
|
||||
| GrpcEvent
|
||||
| GrpcRequest
|
||||
| HttpRequest
|
||||
| HttpResponse
|
||||
| HttpResponseEvent
|
||||
| KeyValue
|
||||
| Plugin
|
||||
| Settings
|
||||
| SyncState
|
||||
| WebsocketConnection
|
||||
| WebsocketEvent
|
||||
| WebsocketRequest
|
||||
| Workspace
|
||||
| WorkspaceMeta;
|
||||
|
||||
export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };
|
||||
export type ClientCertificate = {
|
||||
host: string;
|
||||
port: number | null;
|
||||
crtFile: string | null;
|
||||
keyFile: string | null;
|
||||
pfxFile: string | null;
|
||||
passphrase: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, };
|
||||
export type Cookie = {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: CookieDomain;
|
||||
expires: CookieExpires;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: CookieSameSite | null;
|
||||
};
|
||||
|
||||
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
|
||||
export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
|
||||
|
||||
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
|
||||
export type CookieExpires = { AtUtc: string } | "SessionEnd";
|
||||
|
||||
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
|
||||
export type CookieJar = {
|
||||
model: "cookie_jar";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
cookies: Array<Cookie>;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type CookieSameSite = "Strict" | "Lax" | "None";
|
||||
|
||||
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
|
||||
export type DnsOverride = {
|
||||
hostname: string;
|
||||
ipv4: Array<string>;
|
||||
ipv6: Array<string>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
|
||||
|
||||
export type EncryptedKey = { encryptedKey: string, };
|
||||
export type EncryptedKey = { encryptedKey: string };
|
||||
|
||||
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
|
||||
/**
|
||||
* Variables defined in this environment scope.
|
||||
* Child environments override parent variables by name.
|
||||
*/
|
||||
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
|
||||
export type Environment = {
|
||||
model: "environment";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
name: string;
|
||||
public: boolean;
|
||||
parentModel: string;
|
||||
parentId: string | null;
|
||||
/**
|
||||
* Variables defined in this environment scope.
|
||||
* Child environments override parent variables by name.
|
||||
*/
|
||||
variables: Array<EnvironmentVariable>;
|
||||
color: string | null;
|
||||
sortPriority: number;
|
||||
};
|
||||
|
||||
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||
export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
|
||||
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingRequestMessageSize: InheritedIntSetting, };
|
||||
export type Folder = {
|
||||
model: "folder";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, };
|
||||
export type GraphQlIntrospection = {
|
||||
model: "graphql_introspection";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
content: string | null;
|
||||
};
|
||||
|
||||
export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, };
|
||||
export type GrpcConnection = {
|
||||
model: "grpc_connection";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
elapsed: number;
|
||||
error: string | null;
|
||||
method: string;
|
||||
service: string;
|
||||
status: number;
|
||||
state: GrpcConnectionState;
|
||||
trailers: { [key in string]?: string };
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
||||
|
||||
export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, content: string, error: string | null, eventType: GrpcEventType, metadata: { [key in string]?: string }, status: number | null, };
|
||||
export type GrpcEvent = {
|
||||
model: "grpc_event";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
connectionId: string;
|
||||
content: string;
|
||||
error: string | null;
|
||||
eventType: GrpcEventType;
|
||||
metadata: { [key in string]?: string };
|
||||
status: number | null;
|
||||
};
|
||||
|
||||
export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end";
|
||||
export type GrpcEventType =
|
||||
| "info"
|
||||
| "error"
|
||||
| "client_message"
|
||||
| "server_message"
|
||||
| "connection_start"
|
||||
| "connection_end";
|
||||
|
||||
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number,
|
||||
/**
|
||||
* Server URL (http for plaintext or https for secure)
|
||||
*/
|
||||
url: string, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
|
||||
export type GrpcRequest = {
|
||||
model: "grpc_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authenticationType: string | null;
|
||||
authentication: Record<string, any>;
|
||||
description: string;
|
||||
message: string;
|
||||
metadata: Array<HttpRequestHeader>;
|
||||
method: string | null;
|
||||
name: string;
|
||||
service: string | null;
|
||||
sortPriority: number;
|
||||
/**
|
||||
* Server URL (http for plaintext or https for secure)
|
||||
*/
|
||||
url: string;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string,
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, };
|
||||
export type HttpRequest = {
|
||||
model: "http_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
body: Record<string, any>;
|
||||
bodyType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
method: string;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
url: string;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
|
||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||
export type HttpResponse = {
|
||||
model: "http_response";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
contentLength: number | null;
|
||||
contentLengthCompressed: number | null;
|
||||
elapsed: number;
|
||||
elapsedHeaders: number;
|
||||
elapsedDns: number;
|
||||
error: string | null;
|
||||
headers: Array<HttpResponseHeader>;
|
||||
remoteAddr: string | null;
|
||||
requestContentLength: number | null;
|
||||
requestHeaders: Array<HttpResponseHeader>;
|
||||
status: number;
|
||||
statusReason: string | null;
|
||||
state: HttpResponseState;
|
||||
url: string;
|
||||
version: string | null;
|
||||
};
|
||||
|
||||
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
||||
export type HttpResponseEvent = {
|
||||
model: "http_response_event";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
responseId: string;
|
||||
event: HttpResponseEventData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Serializable representation of HTTP response events for DB storage.
|
||||
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
||||
*/
|
||||
export type HttpResponseEventData = { "type": "setting", name: string, value: string, source_model?: string, source_id?: string, source_name?: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
|
||||
export type HttpResponseEventData =
|
||||
| {
|
||||
type: "setting";
|
||||
name: string;
|
||||
value: string;
|
||||
source_model?: string;
|
||||
source_id?: string;
|
||||
source_name?: string;
|
||||
}
|
||||
| { type: "info"; message: string }
|
||||
| {
|
||||
type: "redirect";
|
||||
url: string;
|
||||
status: number;
|
||||
behavior: string;
|
||||
dropped_body: boolean;
|
||||
dropped_headers: Array<string>;
|
||||
}
|
||||
| {
|
||||
type: "send_url";
|
||||
method: string;
|
||||
scheme: string;
|
||||
username: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
path: string;
|
||||
query: string;
|
||||
fragment: string;
|
||||
}
|
||||
| { type: "receive_url"; version: string; status: string }
|
||||
| { type: "header_up"; name: string; value: string }
|
||||
| { type: "header_down"; name: string; value: string }
|
||||
| { type: "chunk_sent"; bytes: number }
|
||||
| { type: "chunk_received"; bytes: number }
|
||||
| {
|
||||
type: "dns_resolved";
|
||||
hostname: string;
|
||||
addresses: Array<string>;
|
||||
duration: bigint;
|
||||
overridden: boolean;
|
||||
};
|
||||
|
||||
export type HttpResponseHeader = { name: string, value: string, };
|
||||
export type HttpResponseHeader = { name: string; value: string };
|
||||
|
||||
export type HttpResponseState = "initialized" | "connected" | "closed";
|
||||
|
||||
/**
|
||||
* The resolved send settings, values only: what an executor has to obey, with the sources
|
||||
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
|
||||
* crosses from a tab to the Yaak server, and what the server reads.
|
||||
*/
|
||||
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
|
||||
/**
|
||||
* Milliseconds. Zero or negative means no timeout.
|
||||
*/
|
||||
timeoutMs: number, sendCookies: boolean, storeCookies: boolean, };
|
||||
export type HttpUrlParameter = {
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string;
|
||||
value: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type HttpUrlParameter = { enabled?: boolean,
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string, value: string, id?: string, };
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean, value: number, };
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
|
||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||
|
||||
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };
|
||||
export type KeyValue = {
|
||||
model: "key_value";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
key: string;
|
||||
namespace: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type Plugin = {
|
||||
model: "plugin";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
checkedAt: string | null;
|
||||
directory: string;
|
||||
enabled: boolean;
|
||||
url: string | null;
|
||||
source: PluginSource;
|
||||
};
|
||||
|
||||
export type PluginSource = "bundled" | "filesystem" | "registry";
|
||||
|
||||
export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" };
|
||||
export type ProxySetting =
|
||||
| {
|
||||
type: "enabled";
|
||||
http: string;
|
||||
https: string;
|
||||
auth: ProxySettingAuth | null;
|
||||
bypass: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
| { type: "disabled" };
|
||||
|
||||
export type ProxySettingAuth = { user: string, password: string, };
|
||||
export type ProxySettingAuth = { user: string; password: string };
|
||||
|
||||
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, promptFeedback: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, hotkeys: { [key in string]?: Array<string> }, };
|
||||
export type Settings = {
|
||||
model: "settings";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
appearance: string;
|
||||
clientCertificates: Array<ClientCertificate>;
|
||||
coloredMethods: boolean;
|
||||
editorFont: string | null;
|
||||
editorFontSize: number;
|
||||
editorKeymap: EditorKeymap;
|
||||
editorSoftWrap: boolean;
|
||||
hideWindowControls: boolean;
|
||||
useNativeTitlebar: boolean;
|
||||
interfaceFont: string | null;
|
||||
interfaceFontSize: number;
|
||||
interfaceScale: number;
|
||||
openWorkspaceNewWindow: boolean | null;
|
||||
proxy: ProxySetting | null;
|
||||
themeDark: string;
|
||||
themeLight: string;
|
||||
updateChannel: string;
|
||||
hideLicenseBadge: boolean;
|
||||
promptFeedback: boolean;
|
||||
autoupdate: boolean;
|
||||
autoDownloadUpdates: boolean;
|
||||
checkNotifications: boolean;
|
||||
hotkeys: { [key in string]?: Array<string> };
|
||||
};
|
||||
|
||||
export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environment" } & Environment | { "type": "folder" } & Folder | { "type": "http_request" } & HttpRequest | { "type": "grpc_request" } & GrpcRequest | { "type": "websocket_request" } & WebsocketRequest;
|
||||
export type SyncModel =
|
||||
| ({ type: "workspace" } & Workspace)
|
||||
| ({ type: "environment" } & Environment)
|
||||
| ({ type: "folder" } & Folder)
|
||||
| ({ type: "http_request" } & HttpRequest)
|
||||
| ({ type: "grpc_request" } & GrpcRequest)
|
||||
| ({ type: "websocket_request" } & WebsocketRequest);
|
||||
|
||||
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };
|
||||
export type SyncState = {
|
||||
model: "sync_state";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
flushedAt: string;
|
||||
modelId: string;
|
||||
checksum: string;
|
||||
relPath: string;
|
||||
syncDir: string;
|
||||
};
|
||||
|
||||
export type WebsocketConnection = { model: "websocket_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, headers: Array<HttpResponseHeader>, state: WebsocketConnectionState, status: number, url: string, };
|
||||
export type WebsocketConnection = {
|
||||
model: "websocket_connection";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
elapsed: number;
|
||||
error: string | null;
|
||||
headers: Array<HttpResponseHeader>;
|
||||
state: WebsocketConnectionState;
|
||||
status: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
||||
|
||||
export type WebsocketEvent = { model: "websocket_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array<number>, messageType: WebsocketEventType, };
|
||||
export type WebsocketEvent = {
|
||||
model: "websocket_event";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
connectionId: string;
|
||||
isServer: boolean;
|
||||
message: Array<number>;
|
||||
messageType: WebsocketEventType;
|
||||
};
|
||||
|
||||
export type WebsocketEventType = "binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
|
||||
export type WebsocketEventType =
|
||||
| "binary"
|
||||
| "close"
|
||||
| "error"
|
||||
| "frame"
|
||||
| "open"
|
||||
| "ping"
|
||||
| "pong"
|
||||
| "text";
|
||||
|
||||
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
|
||||
export type WebsocketRequest = {
|
||||
model: "websocket_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
message: string;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
url: string;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingRequestMessageSize: number, settingDnsOverrides: Array<DnsOverride>, settingSendCookies: boolean, settingStoreCookies: boolean, };
|
||||
export type Workspace = {
|
||||
model: "workspace";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
name: string;
|
||||
encryptionKeyChallenge: string | null;
|
||||
settingValidateCertificates: boolean;
|
||||
settingFollowRedirects: boolean;
|
||||
settingRequestTimeout: number;
|
||||
settingRequestMessageSize: number;
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
settingHttpVersion: HttpVersion;
|
||||
};
|
||||
|
||||
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
|
||||
export type WorkspaceMeta = {
|
||||
model: "workspace_meta";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
encryptionKey: EncryptedKey | null;
|
||||
settingSyncDir: string | null;
|
||||
};
|
||||
|
||||
@@ -6,14 +6,11 @@
|
||||
//! own environment chain before a plugin sees them, or an auth plugin receives
|
||||
//! `${[ api_key ]}` where it expected a key.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::error::Result;
|
||||
use crate::host::PluginHost;
|
||||
use crate::render::render_json_value;
|
||||
use std::collections::HashMap;
|
||||
use yaak_models::models::AnyModel;
|
||||
use crate::render::render_form_values;
|
||||
use yaak_plugins::events::{
|
||||
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, JsonPrimitive,
|
||||
RenderPurpose,
|
||||
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, RenderPurpose,
|
||||
};
|
||||
use yaak_rpc_schema::*;
|
||||
use yaak_templates::RenderOptions;
|
||||
@@ -31,7 +28,7 @@ pub async fn cmd_get_http_authentication_config<H: PluginHost>(
|
||||
) -> Result<GetHttpAuthenticationConfigResponse> {
|
||||
// A config form is being displayed, so a template that cannot resolve
|
||||
// should show as blank rather than refuse to open the form.
|
||||
let values = render_auth_values(
|
||||
let values = render_form_values(
|
||||
&host,
|
||||
&req.model,
|
||||
req.environment_id.as_deref(),
|
||||
@@ -50,7 +47,7 @@ pub async fn cmd_call_http_authentication_action<H: PluginHost>(
|
||||
) -> Result<()> {
|
||||
// An action actually uses these values, so an unresolvable template is an
|
||||
// error rather than an empty string that would silently authenticate wrong.
|
||||
let values = render_auth_values(
|
||||
let values = render_form_values(
|
||||
&host,
|
||||
&req.model,
|
||||
req.environment_id.as_deref(),
|
||||
@@ -63,40 +60,3 @@ pub async fn cmd_call_http_authentication_action<H: PluginHost>(
|
||||
host.call_http_authentication_action(&req.auth_name, req.action_index, values, req.model.id())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Render the form's values against the environment chain the model sits in.
|
||||
///
|
||||
/// The chain depends on where the model lives — a request inherits through its
|
||||
/// folder, a workspace has only its own — so the model is what decides which
|
||||
/// variables are in scope.
|
||||
async fn render_auth_values<H: PluginHost>(
|
||||
host: &H,
|
||||
model: &AnyModel,
|
||||
environment_id: Option<&str>,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
purpose: RenderPurpose,
|
||||
options: &RenderOptions,
|
||||
) -> Result<HashMap<String, JsonPrimitive>> {
|
||||
let (workspace_id, folder_id) = match model {
|
||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||
other => {
|
||||
return Err(Error::Generic(format!(
|
||||
"Cannot resolve authentication for a {}",
|
||||
other.model()
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let environment_chain =
|
||||
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
|
||||
|
||||
let cb = host.template_callback(purpose);
|
||||
let rendered =
|
||||
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
|
||||
|
||||
Ok(serde_json::from_value(rendered)?)
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ pub trait PluginHost: Host {
|
||||
) -> impl Future<Output = crate::Result<Vec<GetTemplateFunctionSummaryResponse>>>;
|
||||
|
||||
/// The form a template function wants to show for the given values.
|
||||
/// `values` arrive already rendered.
|
||||
fn template_function_config(
|
||||
&self,
|
||||
function_name: &str,
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
//! Rendering a template against an environment chain.
|
||||
//!
|
||||
//! The variables come from the chain, the functions come from the host's
|
||||
//! template callback. Neither of these knows which host it is running under —
|
||||
//! that is the whole point of taking the callback as a parameter.
|
||||
//! template callback. `render_template` and `render_json_value` know nothing
|
||||
//! about which host they run under — that is the whole point of taking the
|
||||
//! callback as a parameter. `render_form_values` sits one level up: resolving
|
||||
//! the chain a model sits in is an ordinary database read, so it takes the
|
||||
//! host and does that read before rendering.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::host::PluginHost;
|
||||
use serde_json::Value;
|
||||
use yaak_models::models::Environment;
|
||||
use std::collections::HashMap;
|
||||
use yaak_models::models::{AnyModel, Environment};
|
||||
use yaak_models::render::make_vars_hashmap;
|
||||
use yaak_plugins::events::{JsonPrimitive, RenderPurpose};
|
||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
||||
|
||||
pub async fn render_template<T: TemplateCallback>(
|
||||
@@ -28,3 +35,40 @@ pub async fn render_json_value<T: TemplateCallback>(
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
render_json_value_raw(value, vars, cb, opt).await
|
||||
}
|
||||
|
||||
/// Render a config form's values against the environment chain the model sits in.
|
||||
///
|
||||
/// The chain depends on where the model lives — a request inherits through its
|
||||
/// folder, a workspace has only its own — so the model is what decides which
|
||||
/// variables are in scope.
|
||||
pub(crate) async fn render_form_values<H: PluginHost>(
|
||||
host: &H,
|
||||
model: &AnyModel,
|
||||
environment_id: Option<&str>,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
purpose: RenderPurpose,
|
||||
options: &RenderOptions,
|
||||
) -> Result<HashMap<String, JsonPrimitive>> {
|
||||
let (workspace_id, folder_id) = match model {
|
||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||
other => {
|
||||
return Err(Error::Generic(format!(
|
||||
"Cannot resolve environments for a {}",
|
||||
other.model()
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let environment_chain =
|
||||
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
|
||||
|
||||
let cb = host.template_callback(purpose);
|
||||
let rendered =
|
||||
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
|
||||
|
||||
Ok(serde_json::from_value(rendered)?)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::host::PluginHost;
|
||||
use crate::render::render_template;
|
||||
use crate::render::{render_form_values, render_template};
|
||||
use yaak_plugins::events::{
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||
RenderPurpose,
|
||||
@@ -56,7 +56,19 @@ pub async fn cmd_template_function_config<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdTemplateFunctionConfigReq,
|
||||
) -> Result<GetTemplateFunctionConfigResponse> {
|
||||
host.template_function_config(&req.function_name, req.values, req.model.id()).await
|
||||
// A config form is being displayed, so a template that cannot resolve
|
||||
// should show as blank rather than refuse to open the form.
|
||||
let values = render_form_values(
|
||||
&host,
|
||||
&req.model,
|
||||
req.environment_id.as_deref(),
|
||||
req.values,
|
||||
RenderPurpose::Preview,
|
||||
&RenderOptions::return_empty(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
host.template_function_config(&req.function_name, values, req.model.id()).await
|
||||
}
|
||||
|
||||
pub async fn cmd_get_themes<H: PluginHost>(
|
||||
|
||||
@@ -18,7 +18,7 @@ use yaak_commands::models::{
|
||||
cmd_default_headers, cmd_get_workspace_meta, models_delete, models_upsert,
|
||||
models_workspace_models,
|
||||
};
|
||||
use yaak_commands::templates::cmd_render_template;
|
||||
use yaak_commands::templates::{cmd_render_template, cmd_template_function_config};
|
||||
use yaak_commands::{Host, PluginHost};
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
@@ -172,6 +172,8 @@ struct SingleThreadedHost {
|
||||
/// The values the last auth-config call arrived with, so a test can check
|
||||
/// they were rendered before the host ever saw them.
|
||||
auth_values: Rc<RefCell<Option<HashMap<String, JsonPrimitive>>>>,
|
||||
/// Same, for the last template-function-config call.
|
||||
fn_values: Rc<RefCell<Option<HashMap<String, JsonPrimitive>>>>,
|
||||
}
|
||||
|
||||
impl Host for SingleThreadedHost {
|
||||
@@ -261,9 +263,10 @@ impl PluginHost for SingleThreadedHost {
|
||||
async fn template_function_config(
|
||||
&self,
|
||||
function_name: &str,
|
||||
_values: HashMap<String, JsonPrimitive>,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
_model_id: &str,
|
||||
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
|
||||
*self.fn_values.borrow_mut() = Some(values);
|
||||
Err(yaak_commands::Error::Generic(format!("no plugin provides {function_name}()")))
|
||||
}
|
||||
|
||||
@@ -376,6 +379,7 @@ async fn a_single_threaded_host_can_implement_the_trait() {
|
||||
let host = SingleThreadedHost {
|
||||
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
||||
auth_values: Rc::new(RefCell::new(None)),
|
||||
fn_values: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
|
||||
let workspace = Workspace { name: "From one thread".to_string(), ..Default::default() };
|
||||
@@ -448,6 +452,7 @@ async fn auth_values_are_rendered_before_the_host_sees_them() {
|
||||
let host = SingleThreadedHost {
|
||||
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
||||
auth_values: Rc::new(RefCell::new(None)),
|
||||
fn_values: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
|
||||
let workspace = host
|
||||
@@ -499,3 +504,65 @@ async fn auth_values_are_rendered_before_the_host_sees_them() {
|
||||
seen.get("password"),
|
||||
);
|
||||
}
|
||||
|
||||
/// Same contract as auth: template function argument values may contain
|
||||
/// templates (the 1Password token argument defaults to `${[1PASSWORD_TOKEN]}`),
|
||||
/// and the shared handler renders them before the host is called.
|
||||
#[tokio::test]
|
||||
async fn template_function_values_are_rendered_before_the_host_sees_them() {
|
||||
let TestHost { inner } = TestHost::new();
|
||||
let host = SingleThreadedHost {
|
||||
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
||||
auth_values: Rc::new(RefCell::new(None)),
|
||||
fn_values: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
|
||||
let workspace = host
|
||||
.db()
|
||||
.upsert_workspace(
|
||||
&Workspace { name: "Functions".to_string(), ..Default::default() },
|
||||
&host.update_source(),
|
||||
)
|
||||
.expect("workspace");
|
||||
host.db()
|
||||
.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: workspace.id.clone(),
|
||||
name: "Env".to_string(),
|
||||
variables: vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "1PASSWORD_TOKEN".to_string(),
|
||||
value: "ops_abc123".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&host.update_source(),
|
||||
)
|
||||
.expect("environment");
|
||||
let environment =
|
||||
host.db().list_environments_ensure_base(&workspace.id).expect("list").remove(0);
|
||||
|
||||
let mut values = HashMap::new();
|
||||
values.insert("token".to_string(), JsonPrimitive::String("${[1PASSWORD_TOKEN]}".to_string()));
|
||||
|
||||
// The host refuses the call itself — it has no plugins — but only after the
|
||||
// handler has rendered and handed over the values, which is what matters.
|
||||
let _ = cmd_template_function_config(
|
||||
host.clone(),
|
||||
yaak_rpc_schema::CmdTemplateFunctionConfigReq {
|
||||
function_name: "1password.item".to_string(),
|
||||
values,
|
||||
model: AnyModel::Workspace(workspace),
|
||||
environment_id: Some(environment.id),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let seen = host.fn_values.borrow().clone().expect("the host should have been called");
|
||||
assert!(
|
||||
matches!(seen.get("token"), Some(JsonPrimitive::String(v)) if v == "ops_abc123"),
|
||||
"the template should have been rendered before reaching the host, got {:?}",
|
||||
seen.get("token"),
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+7
@@ -47,6 +47,7 @@ export type Folder = {
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type GrpcRequest = {
|
||||
@@ -99,6 +100,7 @@ export type HttpRequest = {
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
@@ -114,8 +116,12 @@ export type HttpUrlParameter = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||
|
||||
export type SyncModel =
|
||||
@@ -169,4 +175,5 @@ export type Workspace = {
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
settingHttpVersion: HttpVersion;
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::error::Result;
|
||||
use log::{debug, info, warn};
|
||||
use reqwest::{Client, ClientBuilder, Proxy, redirect};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use yaak_models::models::DnsOverride;
|
||||
use yaak_models::models::{DnsOverride, HttpVersion};
|
||||
use yaak_tls::{
|
||||
ClientCertificateConfig, NativeClientIdentity, get_tls_config, load_native_client_identity,
|
||||
};
|
||||
@@ -39,6 +39,7 @@ impl ConfiguredClient {
|
||||
/// supports TLS 1.0+ for legacy servers.
|
||||
fn build_native_tls_connector(
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
http_version: HttpVersion,
|
||||
) -> Result<native_tls::TlsConnector> {
|
||||
let mut builder = native_tls::TlsConnector::builder();
|
||||
builder.danger_accept_invalid_certs(true);
|
||||
@@ -46,7 +47,11 @@ fn build_native_tls_connector(
|
||||
builder.min_protocol_version(Some(native_tls::Protocol::Tlsv10));
|
||||
// reqwest cannot add ALPN to a connector it did not build, so without this
|
||||
// the native path would silently negotiate HTTP/1.1 for every request.
|
||||
builder.request_alpns(&["h2", "http/1.1"]);
|
||||
match http_version {
|
||||
HttpVersion::Auto => builder.request_alpns(&["h2", "http/1.1"]),
|
||||
HttpVersion::Http1 => builder.request_alpns(&["http/1.1"]),
|
||||
HttpVersion::Http2 => builder.request_alpns(&["h2"]),
|
||||
};
|
||||
|
||||
if let Some(identity) = build_native_tls_identity(client_cert)? {
|
||||
builder.identity(identity);
|
||||
@@ -100,6 +105,7 @@ pub enum HttpConnectionProxySetting {
|
||||
pub struct HttpConnectionOptions {
|
||||
pub id: String,
|
||||
pub validate_certificates: bool,
|
||||
pub http_version: HttpVersion,
|
||||
pub proxy: HttpConnectionProxySetting,
|
||||
pub client_certificate: Option<ClientCertificateConfig>,
|
||||
pub dns_overrides: Vec<DnsOverride>,
|
||||
@@ -128,14 +134,28 @@ impl HttpConnectionOptions {
|
||||
// This is needed so we can emit DNS timing events for each request
|
||||
.pool_max_idle_per_host(0);
|
||||
|
||||
match self.http_version {
|
||||
HttpVersion::Auto => {}
|
||||
HttpVersion::Http1 => client = client.http1_only(),
|
||||
HttpVersion::Http2 => client = client.http2_prior_knowledge(),
|
||||
}
|
||||
|
||||
// Configure TLS
|
||||
if self.validate_certificates {
|
||||
// Use rustls with platform certificate verification (TLS 1.2+ only)
|
||||
let config = get_tls_config(true, true, self.client_certificate.clone())?;
|
||||
let mut config = get_tls_config(true, true, self.client_certificate.clone())?;
|
||||
// A forced version must also constrain ALPN, or the server may
|
||||
// negotiate a protocol the client then refuses to speak
|
||||
match self.http_version {
|
||||
HttpVersion::Auto => {}
|
||||
HttpVersion::Http1 => config.alpn_protocols = vec![b"http/1.1".to_vec()],
|
||||
HttpVersion::Http2 => config.alpn_protocols = vec![b"h2".to_vec()],
|
||||
}
|
||||
client = client.use_preconfigured_tls(config);
|
||||
} else {
|
||||
// Use native TLS for maximum compatibility (supports TLS 1.0+)
|
||||
let connector = build_native_tls_connector(self.client_certificate.clone())?;
|
||||
let connector =
|
||||
build_native_tls_connector(self.client_certificate.clone(), self.http_version)?;
|
||||
client = client.use_preconfigured_tls(connector);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,10 @@ impl HttpConnectionManager {
|
||||
|
||||
pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result<CachedClient> {
|
||||
let mut connections = self.connections.write().await;
|
||||
let id = opt.id.clone();
|
||||
// The key must include any per-request option that changes how the
|
||||
// client is built, or a send after a settings change reuses a client
|
||||
// built with the old value for up to the cache TTL.
|
||||
let id = format!("{}::{}::{}", opt.id, opt.validate_certificates, opt.http_version);
|
||||
|
||||
// Clean old connections
|
||||
connections.retain(|_, (_, last_used)| last_used.elapsed() <= self.ttl);
|
||||
|
||||
+16
-1
@@ -110,6 +110,7 @@ export type Folder = {
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type GraphQlIntrospection = {
|
||||
@@ -214,6 +215,7 @@ export type HttpRequest = {
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
@@ -318,6 +320,7 @@ export type HttpSendSettings = {
|
||||
timeoutMs: number;
|
||||
sendCookies: boolean;
|
||||
storeCookies: boolean;
|
||||
httpVersion: HttpVersion;
|
||||
};
|
||||
|
||||
export type HttpUrlParameter = {
|
||||
@@ -331,8 +334,12 @@ export type HttpUrlParameter = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||
|
||||
export type KeyValue = {
|
||||
@@ -475,7 +482,14 @@ export type WebsocketEvent = {
|
||||
};
|
||||
|
||||
export type WebsocketEventType =
|
||||
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
|
||||
| "binary"
|
||||
| "close"
|
||||
| "error"
|
||||
| "frame"
|
||||
| "open"
|
||||
| "ping"
|
||||
| "pong"
|
||||
| "text";
|
||||
|
||||
export type WebsocketMessageType = "text" | "binary";
|
||||
|
||||
@@ -522,6 +536,7 @@ export type Workspace = {
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
settingHttpVersion: HttpVersion;
|
||||
};
|
||||
|
||||
export type WorkspaceMeta = {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE workspaces ADD COLUMN setting_http_version TEXT DEFAULT 'auto' NOT NULL;
|
||||
|
||||
ALTER TABLE folders ADD COLUMN setting_http_version TEXT DEFAULT '{"enabled":false,"value":"auto"}' NOT NULL;
|
||||
|
||||
ALTER TABLE http_requests ADD COLUMN setting_http_version TEXT DEFAULT '{"enabled":false,"value":"auto"}' NOT NULL;
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::error::Result;
|
||||
use crate::models::HttpRequestIden::{
|
||||
Authentication, AuthenticationType, Body, BodyType, CreatedAt, Description, FolderId, Headers,
|
||||
Method, Name, SettingFollowRedirects, SettingRequestTimeout, SettingSendCookies,
|
||||
SettingStoreCookies, SettingValidateCertificates, SortPriority, UpdatedAt, Url, UrlParameters,
|
||||
WorkspaceId,
|
||||
Method, Name, SettingFollowRedirects, SettingHttpVersion, SettingRequestTimeout,
|
||||
SettingSendCookies, SettingStoreCookies, SettingValidateCertificates, SortPriority, UpdatedAt,
|
||||
Url, UrlParameters, WorkspaceId,
|
||||
};
|
||||
use crate::util::generate_prefixed_id;
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
@@ -143,6 +143,7 @@ pub struct ResolvedHttpRequestSettings {
|
||||
pub request_message_size: ResolvedSetting<i32>,
|
||||
pub send_cookies: ResolvedSetting<bool>,
|
||||
pub store_cookies: ResolvedSetting<bool>,
|
||||
pub http_version: ResolvedSetting<HttpVersion>,
|
||||
}
|
||||
|
||||
impl Default for ResolvedHttpRequestSettings {
|
||||
@@ -154,6 +155,7 @@ impl Default for ResolvedHttpRequestSettings {
|
||||
request_message_size: ResolvedSetting::default_source(DEFAULT_REQUEST_MESSAGE_SIZE),
|
||||
send_cookies: ResolvedSetting::default_source(true),
|
||||
store_cookies: ResolvedSetting::default_source(true),
|
||||
http_version: ResolvedSetting::default_source(HttpVersion::Auto),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,6 +193,7 @@ impl ResolvedHttpRequestSettings {
|
||||
event("timeout", timeout, &self.request_timeout),
|
||||
event("send_cookies", self.send_cookies.value.to_string(), &self.send_cookies),
|
||||
event("store_cookies", self.store_cookies.value.to_string(), &self.store_cookies),
|
||||
event("http_version", self.http_version.value.to_string(), &self.http_version),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -208,6 +211,8 @@ pub struct HttpSendSettings {
|
||||
pub timeout_ms: i32,
|
||||
pub send_cookies: bool,
|
||||
pub store_cookies: bool,
|
||||
#[serde(default)]
|
||||
pub http_version: HttpVersion,
|
||||
}
|
||||
|
||||
impl From<&ResolvedHttpRequestSettings> for HttpSendSettings {
|
||||
@@ -218,6 +223,7 @@ impl From<&ResolvedHttpRequestSettings> for HttpSendSettings {
|
||||
timeout_ms: s.request_timeout.value,
|
||||
send_cookies: s.send_cookies.value,
|
||||
store_cookies: s.store_cookies.value,
|
||||
http_version: s.http_version.value,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,6 +261,49 @@ impl Default for InheritedIntSetting {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub enum HttpVersion {
|
||||
#[default]
|
||||
Auto,
|
||||
Http1,
|
||||
Http2,
|
||||
}
|
||||
|
||||
impl FromStr for HttpVersion {
|
||||
type Err = crate::error::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
match s {
|
||||
"http1" => Ok(Self::Http1),
|
||||
"http2" => Ok(Self::Http2),
|
||||
_ => Ok(Self::Auto),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for HttpVersion {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let str = match self {
|
||||
HttpVersion::Auto => "auto",
|
||||
HttpVersion::Http1 => "http1",
|
||||
HttpVersion::Http2 => "http2",
|
||||
};
|
||||
write!(f, "{}", str)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub struct InheritedHttpVersionSetting {
|
||||
#[serde(default)]
|
||||
#[ts(optional, as = "Option<bool>")]
|
||||
pub enabled: bool,
|
||||
pub value: HttpVersion,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
@@ -484,6 +533,7 @@ impl Default for Workspace {
|
||||
setting_dns_overrides: Vec::new(),
|
||||
setting_send_cookies: true,
|
||||
setting_store_cookies: true,
|
||||
setting_http_version: HttpVersion::Auto,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -516,6 +566,7 @@ pub struct Workspace {
|
||||
pub setting_dns_overrides: Vec<DnsOverride>,
|
||||
pub setting_send_cookies: bool,
|
||||
pub setting_store_cookies: bool,
|
||||
pub setting_http_version: HttpVersion,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for Workspace {
|
||||
@@ -560,6 +611,7 @@ impl UpsertModelInfo for Workspace {
|
||||
(SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()),
|
||||
(SettingSendCookies, self.setting_send_cookies.into()),
|
||||
(SettingStoreCookies, self.setting_store_cookies.into()),
|
||||
(SettingHttpVersion, self.setting_http_version.to_string().into()),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -579,6 +631,7 @@ impl UpsertModelInfo for Workspace {
|
||||
WorkspaceIden::SettingDnsOverrides,
|
||||
WorkspaceIden::SettingSendCookies,
|
||||
WorkspaceIden::SettingStoreCookies,
|
||||
WorkspaceIden::SettingHttpVersion,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -589,6 +642,7 @@ impl UpsertModelInfo for Workspace {
|
||||
let headers: String = row.get("headers")?;
|
||||
let authentication: String = row.get("authentication")?;
|
||||
let setting_dns_overrides: String = row.get("setting_dns_overrides")?;
|
||||
let setting_http_version: String = row.get("setting_http_version")?;
|
||||
Ok(Self {
|
||||
id: row.get("id")?,
|
||||
model: row.get("model")?,
|
||||
@@ -607,6 +661,7 @@ impl UpsertModelInfo for Workspace {
|
||||
setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(),
|
||||
setting_send_cookies: row.get("setting_send_cookies")?,
|
||||
setting_store_cookies: row.get("setting_store_cookies")?,
|
||||
setting_http_version: setting_http_version.parse().unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1078,6 +1133,7 @@ impl Default for Folder {
|
||||
enabled: false,
|
||||
value: DEFAULT_REQUEST_MESSAGE_SIZE,
|
||||
},
|
||||
setting_http_version: InheritedHttpVersionSetting::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1108,6 +1164,7 @@ pub struct Folder {
|
||||
pub setting_follow_redirects: InheritedBoolSetting,
|
||||
pub setting_request_timeout: InheritedIntSetting,
|
||||
pub setting_request_message_size: InheritedIntSetting,
|
||||
pub setting_http_version: InheritedHttpVersionSetting,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for Folder {
|
||||
@@ -1159,6 +1216,7 @@ impl UpsertModelInfo for Folder {
|
||||
SettingRequestMessageSize,
|
||||
serde_json::to_string(&self.setting_request_message_size)?.into(),
|
||||
),
|
||||
(SettingHttpVersion, serde_json::to_string(&self.setting_http_version)?.into()),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1178,6 +1236,7 @@ impl UpsertModelInfo for Folder {
|
||||
FolderIden::SettingFollowRedirects,
|
||||
FolderIden::SettingRequestTimeout,
|
||||
FolderIden::SettingRequestMessageSize,
|
||||
FolderIden::SettingHttpVersion,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1193,6 +1252,7 @@ impl UpsertModelInfo for Folder {
|
||||
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")?;
|
||||
let setting_http_version: String = row.get("setting_http_version")?;
|
||||
Ok(Self {
|
||||
id: row.get("id")?,
|
||||
model: row.get("model")?,
|
||||
@@ -1216,6 +1276,7 @@ impl UpsertModelInfo for Folder {
|
||||
.unwrap_or_default(),
|
||||
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
|
||||
.unwrap_or_else(|_| default_request_message_size_setting()),
|
||||
setting_http_version: serde_json::from_str(&setting_http_version).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1283,6 +1344,7 @@ impl Default for HttpRequest {
|
||||
setting_validate_certificates: InheritedBoolSetting::default(),
|
||||
setting_follow_redirects: InheritedBoolSetting::default(),
|
||||
setting_request_timeout: InheritedIntSetting::default(),
|
||||
setting_http_version: InheritedHttpVersionSetting::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1319,6 +1381,7 @@ pub struct HttpRequest {
|
||||
pub setting_validate_certificates: InheritedBoolSetting,
|
||||
pub setting_follow_redirects: InheritedBoolSetting,
|
||||
pub setting_request_timeout: InheritedIntSetting,
|
||||
pub setting_http_version: InheritedHttpVersionSetting,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for HttpRequest {
|
||||
@@ -1370,6 +1433,7 @@ impl UpsertModelInfo for HttpRequest {
|
||||
),
|
||||
(SettingFollowRedirects, serde_json::to_string(&self.setting_follow_redirects)?.into()),
|
||||
(SettingRequestTimeout, serde_json::to_string(&self.setting_request_timeout)?.into()),
|
||||
(SettingHttpVersion, serde_json::to_string(&self.setting_http_version)?.into()),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1394,6 +1458,7 @@ impl UpsertModelInfo for HttpRequest {
|
||||
SettingValidateCertificates,
|
||||
SettingFollowRedirects,
|
||||
SettingRequestTimeout,
|
||||
SettingHttpVersion,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1407,6 +1472,7 @@ impl UpsertModelInfo for HttpRequest {
|
||||
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_http_version: String = row.get("setting_http_version")?;
|
||||
Ok(Self {
|
||||
id: row.get("id")?,
|
||||
model: row.get("model")?,
|
||||
@@ -1433,6 +1499,7 @@ impl UpsertModelInfo for HttpRequest {
|
||||
.unwrap_or_default(),
|
||||
setting_request_timeout: serde_json::from_str(&setting_request_timeout)
|
||||
.unwrap_or_default(),
|
||||
setting_http_version: serde_json::from_str(&setting_http_version).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +208,14 @@ impl<'a> ClientDb<'a> {
|
||||
} else {
|
||||
parent.store_cookies
|
||||
},
|
||||
http_version: if folder.setting_http_version.enabled {
|
||||
ResolvedSetting::from_model(
|
||||
folder.setting_http_version.value,
|
||||
AnyModel::Folder(folder.clone()),
|
||||
)
|
||||
} else {
|
||||
parent.http_version
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,14 @@ impl<'a> ClientDb<'a> {
|
||||
} else {
|
||||
parent.store_cookies
|
||||
},
|
||||
http_version: if http_request.setting_http_version.enabled {
|
||||
ResolvedSetting::from_model(
|
||||
http_request.setting_http_version.value,
|
||||
AnyModel::HttpRequest(http_request.clone()),
|
||||
)
|
||||
} else {
|
||||
parent.http_version
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -174,7 +182,10 @@ impl<'a> ClientDb<'a> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::init_in_memory;
|
||||
use crate::models::{HttpRequest, HttpRequestHeader};
|
||||
use crate::models::{
|
||||
Folder, HttpRequest, HttpRequestHeader, HttpVersion, InheritedHttpVersionSetting, Workspace,
|
||||
};
|
||||
use crate::util::UpdateSource;
|
||||
|
||||
#[test]
|
||||
fn request_resolution_preserves_duplicate_request_headers() {
|
||||
@@ -210,4 +221,77 @@ mod tests {
|
||||
assert_eq!(cookies[1].value, "optional=1");
|
||||
assert!(!cookies[1].enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_version_resolves_through_the_inheritance_chain() {
|
||||
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
|
||||
let workspace = db
|
||||
.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Test".to_string(),
|
||||
setting_http_version: HttpVersion::Http2,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
.expect("Failed to upsert workspace");
|
||||
|
||||
let folder = db
|
||||
.upsert_folder(
|
||||
&Folder { workspace_id: workspace.id.clone(), ..Default::default() },
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
.expect("Failed to upsert folder");
|
||||
|
||||
let request = db
|
||||
.upsert_http_request(
|
||||
&HttpRequest {
|
||||
workspace_id: workspace.id.clone(),
|
||||
folder_id: Some(folder.id.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
.expect("Failed to upsert request");
|
||||
|
||||
// No overrides, so the workspace base value applies
|
||||
let resolved = db.resolve_settings_for_http_request(&request).expect("Failed to resolve");
|
||||
assert_eq!(resolved.http_version.value, HttpVersion::Http2);
|
||||
assert_eq!(resolved.http_version.source_model, "workspace");
|
||||
|
||||
// A folder override beats the workspace base
|
||||
db.upsert_folder(
|
||||
&Folder {
|
||||
setting_http_version: InheritedHttpVersionSetting {
|
||||
enabled: true,
|
||||
value: HttpVersion::Http1,
|
||||
},
|
||||
..folder
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
.expect("Failed to update folder");
|
||||
let resolved = db.resolve_settings_for_http_request(&request).expect("Failed to resolve");
|
||||
assert_eq!(resolved.http_version.value, HttpVersion::Http1);
|
||||
assert_eq!(resolved.http_version.source_model, "folder");
|
||||
|
||||
// A request override beats them both
|
||||
let request = db
|
||||
.upsert_http_request(
|
||||
&HttpRequest {
|
||||
setting_http_version: InheritedHttpVersionSetting {
|
||||
enabled: true,
|
||||
value: HttpVersion::Auto,
|
||||
},
|
||||
..request
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
.expect("Failed to update request");
|
||||
let resolved = db.resolve_settings_for_http_request(&request).expect("Failed to resolve");
|
||||
assert_eq!(resolved.http_version.value, HttpVersion::Auto);
|
||||
assert_eq!(resolved.http_version.source_model, "http_request");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +96,8 @@ impl<'a> ClientDb<'a> {
|
||||
deleted
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = conn
|
||||
.execute_batch("ROLLBACK TO delete_workspace; RELEASE delete_workspace");
|
||||
let _ =
|
||||
conn.execute_batch("ROLLBACK TO delete_workspace; RELEASE delete_workspace");
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
@@ -177,6 +177,10 @@ impl<'a> ClientDb<'a> {
|
||||
workspace.setting_store_cookies,
|
||||
AnyModel::Workspace(workspace.clone()),
|
||||
),
|
||||
http_version: ResolvedSetting::from_model(
|
||||
workspace.setting_http_version,
|
||||
AnyModel::Workspace(workspace.clone()),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -109,6 +109,7 @@ export type Folder = {
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type GraphQlIntrospection = {
|
||||
@@ -213,6 +214,7 @@ export type HttpRequest = {
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
@@ -314,8 +316,12 @@ export type HttpUrlParameter = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||
|
||||
export type KeyValue = {
|
||||
@@ -378,6 +384,7 @@ export type Settings = {
|
||||
themeLight: string;
|
||||
updateChannel: string;
|
||||
hideLicenseBadge: boolean;
|
||||
promptFeedback: boolean;
|
||||
autoupdate: boolean;
|
||||
autoDownloadUpdates: boolean;
|
||||
checkNotifications: boolean;
|
||||
@@ -428,7 +435,14 @@ export type WebsocketEvent = {
|
||||
};
|
||||
|
||||
export type WebsocketEventType =
|
||||
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
|
||||
| "binary"
|
||||
| "close"
|
||||
| "error"
|
||||
| "frame"
|
||||
| "open"
|
||||
| "ping"
|
||||
| "pong"
|
||||
| "text";
|
||||
|
||||
export type WebsocketRequest = {
|
||||
model: "websocket_request";
|
||||
@@ -473,6 +487,7 @@ export type Workspace = {
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
settingHttpVersion: HttpVersion;
|
||||
};
|
||||
|
||||
export type WorkspaceMeta = {
|
||||
|
||||
Generated
+7
@@ -47,6 +47,7 @@ export type Folder = {
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type GrpcRequest = {
|
||||
@@ -99,6 +100,7 @@ export type HttpRequest = {
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
@@ -114,8 +116,12 @@ export type HttpUrlParameter = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||
|
||||
export type SyncModel =
|
||||
@@ -182,4 +188,5 @@ export type Workspace = {
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
settingHttpVersion: HttpVersion;
|
||||
};
|
||||
|
||||
@@ -8,25 +8,12 @@ use std::future::Future;
|
||||
|
||||
const MAX_DEPTH: usize = 50;
|
||||
|
||||
/// `Send`, except on wasm32, where a template function is a call into
|
||||
/// JavaScript: the future holds a `JsFuture` and the callback an `Rc` pool,
|
||||
/// neither of which can be `Send`. Every other host spawns rendering onto a
|
||||
/// thread pool and needs the bound.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub trait MaybeSend: Send {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl<T: Send> MaybeSend for T {}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub trait MaybeSend {}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
impl<T> MaybeSend for T {}
|
||||
|
||||
pub trait TemplateCallback {
|
||||
fn run(
|
||||
&self,
|
||||
fn_name: &str,
|
||||
args: HashMap<String, serde_json::Value>,
|
||||
) -> impl Future<Output = Result<String>> + MaybeSend;
|
||||
) -> impl Future<Output = Result<String>> + Send;
|
||||
|
||||
fn transform_arg(&self, fn_name: &str, arg_name: &str, arg_value: &str) -> Result<String>;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ crate-type = ["cdylib", "rlib"]
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
md5 = "0.7"
|
||||
yaak-lifecycle = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
# No default features: the template exports belong to @yaakapp-internal/templates, not this module
|
||||
|
||||
@@ -3,12 +3,4 @@
|
||||
// This is loaded by the SharedWorker in packages/platform/src/web/worker.ts and
|
||||
// nowhere else: it owns a SQLite database, and there must be exactly one of it
|
||||
// per origin.
|
||||
export {
|
||||
blob_delete,
|
||||
blob_get,
|
||||
blob_put,
|
||||
boot,
|
||||
prepare_http_send,
|
||||
render_template,
|
||||
rpc,
|
||||
} from "./pkg";
|
||||
export { blob_delete, blob_get, blob_put, boot, prepare_http_send, rpc } from "./pkg";
|
||||
|
||||
Vendored
+7
-16
@@ -26,24 +26,15 @@ export function blob_put(id: string, bytes: Uint8Array): void;
|
||||
export function boot(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Resolve and render a request for sending, exactly as the desktop does: the environment
|
||||
* chain, inherited headers and auth, request settings, the cookie jar. Nothing here touches
|
||||
* a socket.
|
||||
* Resolve and render a request for sending, exactly as the desktop does before it puts the
|
||||
* request on the network: the environment chain, inherited headers and auth, request
|
||||
* settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
|
||||
* posts to the Yaak server.
|
||||
*
|
||||
* `plugins` is the template function bridge: a JS function taking a name and JSON args,
|
||||
* resolving to the rendered string. Passing nothing is allowed.
|
||||
*
|
||||
* Authentication is applied by the caller, not here, because the plugin that applies it
|
||||
* needs to see the request as it will be sent.
|
||||
* Refuses, with a message the user can act on, when the request needs something this host
|
||||
* doesn't have: an authentication plugin, or a template function.
|
||||
*/
|
||||
export function prepare_http_send(payload: any, plugins: any): Promise<any>;
|
||||
|
||||
/**
|
||||
* What `cmd_render_template` does on the desktop. `ignore_error` matches it too: a preview
|
||||
* shows an empty string where a send would refuse, since a half-typed template is not yet a
|
||||
* mistake.
|
||||
*/
|
||||
export function render_template(payload: any, plugins: any): Promise<any>;
|
||||
export function prepare_http_send(payload: any): Promise<any>;
|
||||
|
||||
/**
|
||||
* Run one command as `label` (the calling tab's identity, which stands in for
|
||||
|
||||
@@ -5,5 +5,5 @@ import { __wbg_set_wasm } from "./yaak_wasm_bg.js";
|
||||
__wbg_set_wasm(wasm);
|
||||
wasm.__wbindgen_start();
|
||||
export {
|
||||
blob_delete, blob_get, blob_put, boot, prepare_http_send, render_template, rpc
|
||||
blob_delete, blob_get, blob_put, boot, prepare_http_send, rpc
|
||||
} from "./yaak_wasm_bg.js";
|
||||
|
||||
@@ -63,34 +63,18 @@ export function boot() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and render a request for sending, exactly as the desktop does: the environment
|
||||
* chain, inherited headers and auth, request settings, the cookie jar. Nothing here touches
|
||||
* a socket.
|
||||
* Resolve and render a request for sending, exactly as the desktop does before it puts the
|
||||
* request on the network: the environment chain, inherited headers and auth, request
|
||||
* settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
|
||||
* posts to the Yaak server.
|
||||
*
|
||||
* `plugins` is the template function bridge: a JS function taking a name and JSON args,
|
||||
* resolving to the rendered string. Passing nothing is allowed.
|
||||
*
|
||||
* Authentication is applied by the caller, not here, because the plugin that applies it
|
||||
* needs to see the request as it will be sent.
|
||||
* Refuses, with a message the user can act on, when the request needs something this host
|
||||
* doesn't have: an authentication plugin, or a template function.
|
||||
* @param {any} payload
|
||||
* @param {any} plugins
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export function prepare_http_send(payload, plugins) {
|
||||
const ret = wasm.prepare_http_send(payload, plugins);
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* What `cmd_render_template` does on the desktop. `ignore_error` matches it too: a preview
|
||||
* shows an empty string where a send would refuse, since a half-typed template is not yet a
|
||||
* mistake.
|
||||
* @param {any} payload
|
||||
* @param {any} plugins
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export function render_template(payload, plugins) {
|
||||
const ret = wasm.render_template(payload, plugins);
|
||||
export function prepare_http_send(payload) {
|
||||
const ret = wasm.prepare_http_send(payload);
|
||||
return ret;
|
||||
}
|
||||
|
||||
@@ -241,10 +225,6 @@ export function __wbg_call_dfde26266607c996() { return handleError(function (arg
|
||||
const ret = arg0.call(arg1, arg2);
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_call_faa0a261f288f846() { return handleError(function (arg0, arg1, arg2, arg3) {
|
||||
const ret = arg0.call(arg1, arg2, arg3);
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_clear_bb1b3ff877b62598() { return handleError(function (arg0) {
|
||||
const ret = arg0.clear();
|
||||
return ret;
|
||||
@@ -689,10 +669,6 @@ export function __wbg_then_837494e384b37459(arg0, arg1) {
|
||||
const ret = arg0.then(arg1);
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_then_bd927500e8905df2(arg0, arg1, arg2) {
|
||||
const ret = arg0.then(arg1, arg2);
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_toString_1dda136fd8f30a5f(arg0) {
|
||||
const ret = arg0.toString();
|
||||
return ret;
|
||||
@@ -721,22 +697,22 @@ export function __wbg_warn_b6f36cac66fc96a4(arg0, arg1) {
|
||||
console.warn(arg0, arg1);
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000001(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1140, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1117, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000002(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 229, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 212, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000003(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 74, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__he166673e9c1b4e95);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 83, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000004(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 227, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 210, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f);
|
||||
return ret;
|
||||
}
|
||||
@@ -789,8 +765,8 @@ function wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3(arg0, arg
|
||||
}
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__he166673e9c1b4e95(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__he166673e9c1b4e95(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
|
||||
Binary file not shown.
+2
-3
@@ -5,8 +5,7 @@ export const blob_delete: (a: number, b: number) => [number, number];
|
||||
export const blob_get: (a: number, b: number) => [number, number, number, number];
|
||||
export const blob_put: (a: number, b: number, c: number, d: number) => [number, number];
|
||||
export const boot: () => any;
|
||||
export const prepare_http_send: (a: any, b: any) => any;
|
||||
export const render_template: (a: any, b: any) => any;
|
||||
export const prepare_http_send: (a: any) => any;
|
||||
export const rpc: (a: number, b: number, c: any, d: number, e: number) => [number, number, number];
|
||||
export const rust_sqlite_wasm_abort: () => void;
|
||||
export const rust_sqlite_wasm_assert_fail: (a: number, b: number, c: number, d: number) => void;
|
||||
@@ -19,7 +18,7 @@ export const rust_sqlite_wasm_realloc: (a: number, b: number) => number;
|
||||
export const sqlite3_os_end: () => number;
|
||||
export const sqlite3_os_init: () => number;
|
||||
export const wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__he166673e9c1b4e95: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948: (a: number, b: number, c: any, d: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f: (a: number, b: number) => void;
|
||||
|
||||
+37
-138
@@ -232,14 +232,6 @@ struct PersistSendCookiesReq {
|
||||
after: Vec<Cookie>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PluginKeyValueReq {
|
||||
plugin_name: String,
|
||||
key: String,
|
||||
value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct InsertResponseEventsReq {
|
||||
@@ -423,34 +415,6 @@ fn dispatch(
|
||||
to_json(())
|
||||
}
|
||||
|
||||
// Namespaced by plugin name exactly as `build_shared_reply` does in
|
||||
// crates/yaak/src/plugin_events.rs, so a token is found under the same key on either host.
|
||||
"web_plugin_kv_get" => {
|
||||
let req: PluginKeyValueReq = from_js(payload)?;
|
||||
let found = host.queries.connect().get_plugin_key_value(&req.plugin_name, &req.key);
|
||||
to_json(found.map(|kv| kv.value))
|
||||
}
|
||||
|
||||
"web_plugin_kv_set" => {
|
||||
let req: PluginKeyValueReq = from_js(payload)?;
|
||||
host.queries.connect().set_plugin_key_value(
|
||||
&req.plugin_name,
|
||||
&req.key,
|
||||
&req.value.unwrap_or_default(),
|
||||
);
|
||||
to_json(())
|
||||
}
|
||||
|
||||
"web_plugin_kv_delete" => {
|
||||
let req: PluginKeyValueReq = from_js(payload)?;
|
||||
let deleted = host
|
||||
.queries
|
||||
.connect()
|
||||
.delete_plugin_key_value(&req.plugin_name, &req.key)
|
||||
.map_err(js_error)?;
|
||||
to_json(deleted)
|
||||
}
|
||||
|
||||
other => Err(js_error(format!("yaak-web: `{other}` is not a command this host answers"))),
|
||||
}
|
||||
}
|
||||
@@ -475,9 +439,6 @@ struct PreparedHttpSend {
|
||||
/// The request with inherited headers and authentication applied and every template
|
||||
/// rendered. What the proxy sends, and what the response records as its request.
|
||||
request: HttpRequest,
|
||||
/// Whichever model the auth was inherited from, hashed as the desktop hashes it. An
|
||||
/// OAuth token cache belongs to the folder that declared the auth, not to each request.
|
||||
auth_context_id: String,
|
||||
settings: HttpSendSettings,
|
||||
/// The `* Setting name=value` timeline lines the desktop writes at the top of a send,
|
||||
/// sources and all. The tab records them before the proxy's own events.
|
||||
@@ -486,44 +447,22 @@ struct PreparedHttpSend {
|
||||
cookie_jar: Option<CookieJar>,
|
||||
}
|
||||
|
||||
/// Reaches a template function through a JavaScript function the worker installed, which
|
||||
/// forwards to the plugin sandbox. Without one, a template function is a refusal naming it
|
||||
/// rather than an empty string sent in its place.
|
||||
struct JsTemplateCallback {
|
||||
call: Option<js_sys::Function>,
|
||||
}
|
||||
/// A template callback for a host with no plugins. Variables render; a function is a clear
|
||||
/// refusal naming the function, so the user knows what the request needs rather than seeing
|
||||
/// an empty string sent in its place.
|
||||
struct NoPluginsCallback;
|
||||
|
||||
impl TemplateCallback for JsTemplateCallback {
|
||||
impl TemplateCallback for NoPluginsCallback {
|
||||
fn run(
|
||||
&self,
|
||||
fn_name: &str,
|
||||
args: HashMap<String, serde_json::Value>,
|
||||
) -> impl std::future::Future<Output = yaak_templates::error::Result<String>> {
|
||||
let call = self.call.clone();
|
||||
let fn_name = fn_name.to_string();
|
||||
let args = serde_json::to_string(&args).unwrap_or_else(|_| "{}".into());
|
||||
|
||||
async move {
|
||||
use yaak_templates::error::Error::RenderError;
|
||||
|
||||
let Some(call) = call else {
|
||||
return Err(RenderError(format!(
|
||||
"This request uses the template function \"{fn_name}\", which needs plugins. \
|
||||
No plugin provides it"
|
||||
)));
|
||||
};
|
||||
|
||||
let promise = call
|
||||
.call2(&JsValue::NULL, &JsValue::from_str(&fn_name), &JsValue::from_str(&args))
|
||||
.map_err(|e| RenderError(js_message(&e)))?;
|
||||
let value = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(promise))
|
||||
.await
|
||||
.map_err(|e| RenderError(js_message(&e)))?;
|
||||
|
||||
value.as_string().ok_or_else(|| {
|
||||
RenderError(format!("Template function \"{fn_name}\" did not return a string"))
|
||||
})
|
||||
}
|
||||
_args: HashMap<String, serde_json::Value>,
|
||||
) -> impl std::future::Future<Output = yaak_templates::error::Result<String>> + Send {
|
||||
let message = format!(
|
||||
"This request uses the template function \"{fn_name}\", which needs plugins. \
|
||||
Plugins aren't available in the browser yet"
|
||||
);
|
||||
async move { Err(yaak_templates::error::Error::RenderError(message)) }
|
||||
}
|
||||
|
||||
fn transform_arg(
|
||||
@@ -536,35 +475,19 @@ impl TemplateCallback for JsTemplateCallback {
|
||||
}
|
||||
}
|
||||
|
||||
fn js_message(value: &JsValue) -> String {
|
||||
if let Some(text) = value.as_string() {
|
||||
return text;
|
||||
}
|
||||
let message = js_sys::Reflect::get(value, &JsValue::from_str("message"))
|
||||
.ok()
|
||||
.and_then(|m| m.as_string());
|
||||
message.unwrap_or_else(|| format!("{value:?}"))
|
||||
}
|
||||
|
||||
fn template_callback(plugins: JsValue) -> JsTemplateCallback {
|
||||
JsTemplateCallback { call: plugins.dyn_into::<js_sys::Function>().ok() }
|
||||
}
|
||||
|
||||
/// Resolve and render a request for sending, exactly as the desktop does: the environment
|
||||
/// chain, inherited headers and auth, request settings, the cookie jar. Nothing here touches
|
||||
/// a socket.
|
||||
/// Resolve and render a request for sending, exactly as the desktop does before it puts the
|
||||
/// request on the network: the environment chain, inherited headers and auth, request
|
||||
/// settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
|
||||
/// posts to the Yaak server.
|
||||
///
|
||||
/// `plugins` is the template function bridge: a JS function taking a name and JSON args,
|
||||
/// resolving to the rendered string. Passing nothing is allowed.
|
||||
///
|
||||
/// Authentication is applied by the caller, not here, because the plugin that applies it
|
||||
/// needs to see the request as it will be sent.
|
||||
/// Refuses, with a message the user can act on, when the request needs something this host
|
||||
/// doesn't have: an authentication plugin, or a template function.
|
||||
#[wasm_bindgen]
|
||||
pub async fn prepare_http_send(payload: JsValue, plugins: JsValue) -> Result<JsValue> {
|
||||
pub async fn prepare_http_send(payload: JsValue) -> Result<JsValue> {
|
||||
let req: PrepareHttpSendReq = from_js(payload)?;
|
||||
|
||||
// Everything from the database first, then release the host borrow before rendering.
|
||||
let (request, environment_chain, settings, cookie_jar, auth_context_id) = with_host(|host| {
|
||||
let (request, environment_chain, settings, cookie_jar) = with_host(|host| {
|
||||
let db = host.queries.connect();
|
||||
let request = db.get_http_request(&req.request_id).map_err(js_error)?;
|
||||
let environment_chain = db
|
||||
@@ -574,7 +497,7 @@ pub async fn prepare_http_send(payload: JsValue, plugins: JsValue) -> Result<JsV
|
||||
req.environment_id.as_deref(),
|
||||
)
|
||||
.map_err(js_error)?;
|
||||
let (authentication_type, authentication, auth_context_id) =
|
||||
let (authentication_type, authentication, _auth_context_id) =
|
||||
db.resolve_auth_for_http_request(&request).map_err(js_error)?;
|
||||
let headers = db.resolve_headers_for_http_request(&request).map_err(js_error)?;
|
||||
let settings = db.resolve_settings_for_http_request(&request).map_err(js_error)?;
|
||||
@@ -583,21 +506,34 @@ pub async fn prepare_http_send(payload: JsValue, plugins: JsValue) -> Result<JsV
|
||||
None => None,
|
||||
};
|
||||
let request = HttpRequest { authentication_type, authentication, headers, ..request };
|
||||
Ok((request, environment_chain, settings, cookie_jar, auth_context_id))
|
||||
Ok((request, environment_chain, settings, cookie_jar))
|
||||
})?;
|
||||
|
||||
let rendered = render_http_request(
|
||||
&request,
|
||||
environment_chain,
|
||||
&template_callback(plugins),
|
||||
&NoPluginsCallback,
|
||||
&RenderOptions::throw(),
|
||||
)
|
||||
.await
|
||||
.map_err(js_error)?;
|
||||
|
||||
// Authentication is applied by a plugin on the desktop. There is no plugin here, and a
|
||||
// request sent without the auth it asked for is worse than one refused with the reason.
|
||||
let auth_disabled =
|
||||
rendered.authentication.get("disabled").and_then(|v| v.as_bool()) == Some(true);
|
||||
if let Some(auth_type) = rendered.authentication_type.as_deref()
|
||||
&& auth_type != "none"
|
||||
&& !auth_disabled
|
||||
{
|
||||
return Err(js_error(format!(
|
||||
"This request uses {auth_type} authentication, which needs plugins. \
|
||||
Plugins aren't available in the browser yet"
|
||||
)));
|
||||
}
|
||||
|
||||
let prepared = PreparedHttpSend {
|
||||
request: rendered,
|
||||
auth_context_id: format!("{:x}", md5::compute(auth_context_id)),
|
||||
settings: HttpSendSettings::from(&settings),
|
||||
setting_events: settings.timeline_events(),
|
||||
cookie_jar,
|
||||
@@ -608,43 +544,6 @@ pub async fn prepare_http_send(payload: JsValue, plugins: JsValue) -> Result<JsV
|
||||
prepared.serialize(&serde_wasm_bindgen::Serializer::json_compatible()).map_err(js_error)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RenderTemplateReq {
|
||||
template: String,
|
||||
workspace_id: String,
|
||||
environment_id: Option<String>,
|
||||
ignore_error: Option<bool>,
|
||||
}
|
||||
|
||||
/// What `cmd_render_template` does on the desktop. `ignore_error` matches it too: a preview
|
||||
/// shows an empty string where a send would refuse, since a half-typed template is not yet a
|
||||
/// mistake.
|
||||
#[wasm_bindgen]
|
||||
pub async fn render_template(payload: JsValue, plugins: JsValue) -> Result<JsValue> {
|
||||
let req: RenderTemplateReq = from_js(payload)?;
|
||||
|
||||
let environment_chain = with_host(|host| {
|
||||
host.queries
|
||||
.connect()
|
||||
.resolve_environments(&req.workspace_id, None, req.environment_id.as_deref())
|
||||
.map_err(js_error)
|
||||
})?;
|
||||
|
||||
let vars = yaak_models::render::make_vars_hashmap(environment_chain);
|
||||
let options = if req.ignore_error == Some(true) {
|
||||
RenderOptions::return_empty()
|
||||
} else {
|
||||
RenderOptions::throw()
|
||||
};
|
||||
|
||||
let rendered =
|
||||
yaak_templates::parse_and_render(&req.template, &vars, &template_callback(plugins), &options)
|
||||
.await
|
||||
.map_err(js_error)?;
|
||||
to_json(rendered).map(|v| JsValue::from_str(v.as_str().unwrap_or_default()))
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Blobs */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
@@ -190,6 +190,7 @@ impl SendRequestExecutor for ConnectionManagerSendRequestExecutor<'_> {
|
||||
.get_client(&HttpConnectionOptions {
|
||||
id: self.plugin_context_id.clone(),
|
||||
validate_certificates: runtime_config.settings.validate_certificates.value,
|
||||
http_version: runtime_config.settings.http_version.value,
|
||||
proxy: runtime_config.proxy.clone(),
|
||||
client_certificate,
|
||||
dns_overrides: runtime_config.dns_overrides.clone(),
|
||||
|
||||
Generated
-40
@@ -16,7 +16,6 @@
|
||||
"packages/platform",
|
||||
"packages/plugin-runtime",
|
||||
"packages/plugin-runtime-types",
|
||||
"packages/plugin-sandbox",
|
||||
"plugins-external/mcp-server",
|
||||
"plugins-external/faker",
|
||||
"plugins-external/httpsnippet",
|
||||
@@ -1471,21 +1470,6 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jitl/quickjs-ffi-types": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz",
|
||||
"integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jitl/quickjs-ng-wasmfile-release-sync": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ng-wasmfile-release-sync/-/quickjs-ng-wasmfile-release-sync-0.32.0.tgz",
|
||||
"integrity": "sha512-XAX2jjZWWh3M0YaRqi82xMKNW/gkF6mo3MpW3UY2cmVxnQai1JuboVsJQVoLU629iEL4XWvHtO4h5lo7NRnAcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -5654,10 +5638,6 @@
|
||||
"resolved": "packages/plugin-runtime",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@yaakapp-internal/plugin-sandbox": {
|
||||
"resolved": "packages/plugin-sandbox",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@yaakapp-internal/plugins": {
|
||||
"resolved": "crates/yaak-plugins",
|
||||
"link": true
|
||||
@@ -12819,15 +12799,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quickjs-emscripten-core": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz",
|
||||
"integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/railroad-diagrams": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz",
|
||||
@@ -15888,17 +15859,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/plugin-sandbox": {
|
||||
"name": "@yaakapp-internal/plugin-sandbox",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ng-wasmfile-release-sync": "^0.32.0",
|
||||
"quickjs-emscripten-core": "^0.32.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.28.0"
|
||||
}
|
||||
},
|
||||
"packages/tailwind-config": {
|
||||
"name": "@yaakapp-internal/tailwind-config",
|
||||
"version": "1.0.0"
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"packages/platform",
|
||||
"packages/plugin-runtime",
|
||||
"packages/plugin-runtime-types",
|
||||
"packages/plugin-sandbox",
|
||||
"plugins-external/mcp-server",
|
||||
"plugins-external/faker",
|
||||
"plugins-external/httpsnippet",
|
||||
|
||||
@@ -2,5 +2,3 @@ export * from "./debounce";
|
||||
export * from "./eagerDebounceAsync";
|
||||
export * from "./formatSize";
|
||||
export * from "./templateFunction";
|
||||
export * from "./pluginForms";
|
||||
export * from "./responseBody";
|
||||
|
||||
@@ -1,403 +0,0 @@
|
||||
/**
|
||||
* `ctx`, built once for every runtime that has one. A runtime supplies only how
|
||||
* a payload reaches its host.
|
||||
*
|
||||
* `stream` and `form` are optional because they are the two places a host
|
||||
* genuinely differs: both need a conversation rather than one reply.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CallPromptFormDynamicArgs,
|
||||
Context,
|
||||
DynamicPromptFormArg,
|
||||
} from "@yaakapp/api";
|
||||
import type {
|
||||
DeleteKeyValueResponse,
|
||||
DeleteModelResponse,
|
||||
FindHttpResponsesResponse,
|
||||
Folder,
|
||||
FormInput,
|
||||
GetCookieValueRequest,
|
||||
GetCookieValueResponse,
|
||||
GetHttpRequestByIdResponse,
|
||||
GetHttpResponseBodyInfoResponse,
|
||||
GetKeyValueResponse,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
InternalEventPayload,
|
||||
ListCookieNamesResponse,
|
||||
ListFoldersResponse,
|
||||
ListHttpRequestsRequest,
|
||||
ListHttpRequestsResponse,
|
||||
ListOpenWorkspacesResponse,
|
||||
PluginContext,
|
||||
PromptFormResponse,
|
||||
PromptTextResponse,
|
||||
ReadHttpResponseBodyChunkResponse,
|
||||
RenderGrpcRequestResponse,
|
||||
RenderHttpRequestResponse,
|
||||
SendHttpRequestResponse,
|
||||
TemplateRenderRequest,
|
||||
TemplateRenderResponse,
|
||||
UpsertModelResponse,
|
||||
WindowInfoResponse,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
import { applyDynamicFormInput, stripDynamicCallbacks } from "./pluginForms";
|
||||
import { createResponseBody, decodeBase64Chunk } from "./responseBody";
|
||||
import { applyFormInputDefaults } from "./templateFunction";
|
||||
|
||||
export interface PluginTransport {
|
||||
request(
|
||||
context: PluginContext,
|
||||
payload: InternalEventPayload,
|
||||
): Promise<Record<string, unknown>>;
|
||||
|
||||
notify(context: PluginContext, payload: InternalEventPayload): void;
|
||||
|
||||
/** Send once, keep receiving. Windows report navigation until they close. */
|
||||
stream?(
|
||||
context: PluginContext,
|
||||
payload: InternalEventPayload,
|
||||
onReply: (payload: InternalEventPayload) => void,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* A form that may re-render before it settles: `onChange` answers with the
|
||||
* form to show next. Without it, a form is drawn once from its defaults.
|
||||
*/
|
||||
form?(
|
||||
context: PluginContext,
|
||||
payload: InternalEventPayload,
|
||||
onChange: (
|
||||
values: Record<string, unknown>,
|
||||
) => Promise<InternalEventPayload | null>,
|
||||
): Promise<PromptFormResponse>;
|
||||
}
|
||||
|
||||
/** `bodyPath` names a file on a host's disk; plugins address bodies by id. */
|
||||
function forPlugin(httpResponse: HttpResponse): HttpResponse {
|
||||
const { bodyPath: _bodyPath, ...rest } = httpResponse as HttpResponse & {
|
||||
bodyPath?: string | null;
|
||||
};
|
||||
return rest;
|
||||
}
|
||||
|
||||
export function createPluginContext(
|
||||
transport: PluginTransport,
|
||||
context: PluginContext,
|
||||
): Context {
|
||||
const send = <T>(payload: InternalEventPayload): Promise<T> =>
|
||||
transport.request(context, payload) as Promise<T>;
|
||||
|
||||
const storedBody = async (responseId: string) => {
|
||||
const bodyInfo = () =>
|
||||
send<GetHttpResponseBodyInfoResponse>({
|
||||
type: "get_http_response_body_info_request",
|
||||
responseId,
|
||||
});
|
||||
const info = await bodyInfo();
|
||||
|
||||
return createResponseBody(
|
||||
{
|
||||
responseId,
|
||||
contentLength: info.contentLength,
|
||||
contentType: info.contentType ?? null,
|
||||
complete: info.complete,
|
||||
},
|
||||
async (offset, length) => {
|
||||
const chunk = await send<ReadHttpResponseBodyChunkResponse>({
|
||||
type: "read_http_response_body_chunk_request",
|
||||
responseId,
|
||||
offset,
|
||||
length,
|
||||
});
|
||||
return decodeBase64Chunk(chunk.data);
|
||||
},
|
||||
{ refresh: bodyInfo },
|
||||
);
|
||||
};
|
||||
|
||||
const windowInfo = async () => {
|
||||
if (context.label == null) {
|
||||
throw new Error("Can't get window context without an active window");
|
||||
}
|
||||
return send<WindowInfoResponse>({ type: "window_info_request", label: context.label });
|
||||
};
|
||||
|
||||
const ctx: Context = {
|
||||
clipboard: {
|
||||
copyText: async (text) => {
|
||||
await send({ type: "copy_text_request", text });
|
||||
},
|
||||
},
|
||||
toast: {
|
||||
show: async (args) => {
|
||||
await send({
|
||||
type: "show_toast_request",
|
||||
// Defaulted here because null and undefined both become None in Rust.
|
||||
timeout: args.timeout === undefined ? 5000 : args.timeout,
|
||||
...args,
|
||||
});
|
||||
},
|
||||
},
|
||||
window: {
|
||||
requestId: async () => (await windowInfo()).requestId,
|
||||
workspaceId: async () => (await windowInfo()).workspaceId,
|
||||
environmentId: async () => (await windowInfo()).environmentId,
|
||||
openUrl: async ({ onNavigate, onClose, ...args }) => {
|
||||
if (transport.stream == null) {
|
||||
throw new Error("ctx.window.openUrl is not available in this runtime");
|
||||
}
|
||||
args.label = args.label || `${Math.random()}`;
|
||||
transport.stream(context, { type: "open_window_request", ...args }, (event) => {
|
||||
if (event.type === "window_navigate_event") onNavigate?.(event);
|
||||
else if (event.type === "window_close_event") onClose?.();
|
||||
});
|
||||
return {
|
||||
close: () => {
|
||||
transport.notify(context, { type: "close_window_request", label: args.label });
|
||||
},
|
||||
};
|
||||
},
|
||||
openExternalUrl: async (url) => {
|
||||
await send({ type: "open_external_url_request", url });
|
||||
},
|
||||
},
|
||||
prompt: {
|
||||
text: async (args) => {
|
||||
const reply = await send<PromptTextResponse>({ type: "prompt_text_request", ...args });
|
||||
return reply.value;
|
||||
},
|
||||
form: async (args) => {
|
||||
// Inputs may compute from the values entered so far, and a function
|
||||
// cannot cross to a host.
|
||||
const resolve = async (values: Record<string, unknown>) => {
|
||||
const callArgs: CallPromptFormDynamicArgs = { values } as CallPromptFormDynamicArgs;
|
||||
const resolved = await applyDynamicFormInput(
|
||||
ctx,
|
||||
args.inputs as DynamicPromptFormArg[],
|
||||
callArgs,
|
||||
);
|
||||
return stripDynamicCallbacks(resolved) as FormInput[];
|
||||
};
|
||||
|
||||
const initial = await resolve(applyFormInputDefaults(args.inputs, {}));
|
||||
const payload: InternalEventPayload = {
|
||||
type: "prompt_form_request",
|
||||
...args,
|
||||
inputs: initial,
|
||||
};
|
||||
|
||||
if (transport.form == null) {
|
||||
const reply = await send<PromptFormResponse>(payload);
|
||||
return reply.values;
|
||||
}
|
||||
|
||||
const reply = await transport.form(context, payload, async (values) => {
|
||||
// Fired on mount, before there is anything to recompute from.
|
||||
if (values == null || Object.keys(values).length === 0) return null;
|
||||
return { type: "prompt_form_request", ...args, inputs: await resolve(values) };
|
||||
});
|
||||
return reply.values;
|
||||
},
|
||||
},
|
||||
httpResponse: {
|
||||
find: async (args) => {
|
||||
const { httpResponses } = await send<FindHttpResponsesResponse>({
|
||||
type: "find_http_responses_request",
|
||||
...args,
|
||||
});
|
||||
return httpResponses.map(forPlugin);
|
||||
},
|
||||
body: ({ responseId }) => storedBody(responseId),
|
||||
},
|
||||
grpcRequest: {
|
||||
render: async (args) => {
|
||||
const { grpcRequest } = await send<RenderGrpcRequestResponse>({
|
||||
type: "render_grpc_request_request",
|
||||
...args,
|
||||
});
|
||||
return grpcRequest;
|
||||
},
|
||||
},
|
||||
httpRequest: {
|
||||
getById: async (args) => {
|
||||
const { httpRequest } = await send<GetHttpRequestByIdResponse>({
|
||||
type: "get_http_request_by_id_request",
|
||||
...args,
|
||||
});
|
||||
return httpRequest;
|
||||
},
|
||||
send: async (args) => {
|
||||
const { httpResponse, body } = await send<SendHttpRequestResponse>({
|
||||
type: "send_http_request_request",
|
||||
...args,
|
||||
});
|
||||
|
||||
// A send with no request behind it saves nothing, so the reply carries
|
||||
// the only copy of its body.
|
||||
if (body == null) {
|
||||
return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) };
|
||||
}
|
||||
|
||||
const bytes = decodeBase64Chunk(body);
|
||||
return {
|
||||
httpResponse: forPlugin(httpResponse),
|
||||
body: createResponseBody(
|
||||
{
|
||||
responseId: httpResponse.id,
|
||||
contentLength: bytes.byteLength,
|
||||
contentType:
|
||||
httpResponse.headers.find((h) => h.name.toLowerCase() === "content-type")?.value ??
|
||||
null,
|
||||
// The host waited for the whole send before replying.
|
||||
complete: true,
|
||||
},
|
||||
async (offset, length) => bytes.slice(offset, offset + length),
|
||||
),
|
||||
};
|
||||
},
|
||||
render: async (args) => {
|
||||
const { httpRequest } = await send<RenderHttpRequestResponse>({
|
||||
type: "render_http_request_request",
|
||||
...args,
|
||||
});
|
||||
return httpRequest;
|
||||
},
|
||||
list: async (args?: { folderId?: string }) => {
|
||||
const payload: InternalEventPayload = {
|
||||
type: "list_http_requests_request",
|
||||
folderId: args?.folderId,
|
||||
} satisfies ListHttpRequestsRequest & { type: "list_http_requests_request" };
|
||||
const { httpRequests } = await send<ListHttpRequestsResponse>(payload);
|
||||
return httpRequests;
|
||||
},
|
||||
create: async (args) => {
|
||||
const response = await send<UpsertModelResponse>({
|
||||
type: "upsert_model_request",
|
||||
model: { name: "", method: "GET", ...args, id: "", model: "http_request" },
|
||||
} as InternalEventPayload);
|
||||
return response.model as HttpRequest;
|
||||
},
|
||||
update: async (args) => {
|
||||
const response = await send<UpsertModelResponse>({
|
||||
type: "upsert_model_request",
|
||||
model: { model: "http_request", ...args },
|
||||
} as InternalEventPayload);
|
||||
return response.model as HttpRequest;
|
||||
},
|
||||
delete: async (args) => {
|
||||
const response = await send<DeleteModelResponse>({
|
||||
type: "delete_model_request",
|
||||
model: "http_request",
|
||||
id: args.id,
|
||||
} as InternalEventPayload);
|
||||
return response.model as HttpRequest;
|
||||
},
|
||||
},
|
||||
folder: {
|
||||
list: async () => {
|
||||
const { folders } = await send<ListFoldersResponse>({ type: "list_folders_request" });
|
||||
return folders;
|
||||
},
|
||||
getById: async (args: { id: string }) => {
|
||||
const { folders } = await send<ListFoldersResponse>({ type: "list_folders_request" });
|
||||
return folders.find((f) => f.id === args.id) ?? null;
|
||||
},
|
||||
create: async ({ name, ...args }) => {
|
||||
const response = await send<UpsertModelResponse>({
|
||||
type: "upsert_model_request",
|
||||
model: { ...args, name: name ?? "", id: "", model: "folder" },
|
||||
} as InternalEventPayload);
|
||||
return response.model as Folder;
|
||||
},
|
||||
update: async (args) => {
|
||||
const response = await send<UpsertModelResponse>({
|
||||
type: "upsert_model_request",
|
||||
model: { model: "folder", ...args },
|
||||
} as InternalEventPayload);
|
||||
return response.model as Folder;
|
||||
},
|
||||
delete: async (args: { id: string }) => {
|
||||
const response = await send<DeleteModelResponse>({
|
||||
type: "delete_model_request",
|
||||
model: "folder",
|
||||
id: args.id,
|
||||
} as InternalEventPayload);
|
||||
return response.model as Folder;
|
||||
},
|
||||
},
|
||||
cookies: {
|
||||
getValue: async (args: GetCookieValueRequest) => {
|
||||
const { value } = await send<GetCookieValueResponse>({
|
||||
type: "get_cookie_value_request",
|
||||
...args,
|
||||
});
|
||||
return value;
|
||||
},
|
||||
listNames: async () => {
|
||||
const { names } = await send<ListCookieNamesResponse>({ type: "list_cookie_names_request" });
|
||||
return names;
|
||||
},
|
||||
},
|
||||
templates: {
|
||||
render: async (args: TemplateRenderRequest) => {
|
||||
const result = await send<TemplateRenderResponse>({
|
||||
type: "template_render_request",
|
||||
...args,
|
||||
});
|
||||
// oxlint-disable-next-line no-explicit-any -- the caller knows its own shape
|
||||
return result.data as any;
|
||||
},
|
||||
},
|
||||
store: {
|
||||
get: async <T>(key: string) => {
|
||||
const result = await send<GetKeyValueResponse>({ type: "get_key_value_request", key });
|
||||
return result.value ? (JSON.parse(result.value) as T) : undefined;
|
||||
},
|
||||
set: async <T>(key: string, value: T) => {
|
||||
await send<GetKeyValueResponse>({
|
||||
type: "set_key_value_request",
|
||||
key,
|
||||
value: JSON.stringify(value),
|
||||
});
|
||||
},
|
||||
delete: async (key: string) => {
|
||||
const result = await send<DeleteKeyValueResponse>({
|
||||
type: "delete_key_value_request",
|
||||
key,
|
||||
});
|
||||
return result.deleted;
|
||||
},
|
||||
},
|
||||
plugin: {
|
||||
reload: () => {
|
||||
transport.notify(context, { type: "reload_response", silent: true });
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
list: async () => {
|
||||
const response = await send<ListOpenWorkspacesResponse>({
|
||||
type: "list_open_workspaces_request",
|
||||
});
|
||||
return response.workspaces.map((w) => {
|
||||
type WorkspaceInfoInternal = typeof w & { label?: string };
|
||||
return {
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
// Kept for routing, hidden from plugin authors.
|
||||
_label: (w as WorkspaceInfoInternal).label as string,
|
||||
};
|
||||
});
|
||||
},
|
||||
withContext: (handle: { id: string; name: string; _label?: string }) =>
|
||||
createPluginContext(transport, {
|
||||
...context,
|
||||
label: handle._label || null,
|
||||
workspaceId: handle.id,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
return ctx;
|
||||
}
|
||||
@@ -17,22 +17,15 @@
|
||||
* up here as a type error rather than as a runtime surprise.
|
||||
*/
|
||||
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import type { JsonPrimitive } from "@yaakapp-internal/plugins";
|
||||
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
|
||||
import type { CapabilityName, RpcPayload } from "../types";
|
||||
import type { WorkerConnection } from "./connection";
|
||||
import { unsupported } from "./errors";
|
||||
import type { WebPlugins } from "./plugins";
|
||||
import { sendHttpRequest } from "./send";
|
||||
|
||||
export type AppCmd = keyof RpcSchema;
|
||||
|
||||
type Handler = (
|
||||
payload: RpcPayload,
|
||||
db: WorkerConnection,
|
||||
plugins: WebPlugins,
|
||||
) => Promise<unknown>;
|
||||
type Handler = (payload: RpcPayload, db: WorkerConnection) => Promise<unknown>;
|
||||
|
||||
/** Placeholder shown wherever the desktop would show a real filesystem path. */
|
||||
const NO_PATH = "";
|
||||
@@ -48,31 +41,6 @@ function text(payload: RpcPayload, key: string): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/** Form values as the plugin protocol carries them. */
|
||||
function values(payload: RpcPayload, key = "values"): Record<string, JsonPrimitive> {
|
||||
const value = payload[key];
|
||||
return value != null && typeof value === "object"
|
||||
? (value as Record<string, JsonPrimitive>)
|
||||
: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The id a plugin keys its stored state on.
|
||||
*
|
||||
* The desktop hashes the id of whichever model the configuration was read from,
|
||||
* so two requests inheriting one folder's authentication share a token cache.
|
||||
* The preview paths here have no such model in hand and pass what they were
|
||||
* given, which is enough to be stable per form.
|
||||
*/
|
||||
function contextId(payload: RpcPayload): string {
|
||||
const model = payload.model;
|
||||
if (model != null && typeof model === "object" && "id" in model) {
|
||||
const id = (model as { id?: unknown }).id;
|
||||
return typeof id === "string" ? id : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Commands this host answers itself.
|
||||
*
|
||||
@@ -105,16 +73,10 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
|
||||
// The tab renders and stores; a stateless server puts the bytes on the wire.
|
||||
// See send.ts for the whole shape of it.
|
||||
cmd_send_http_request: (payload, db, plugins) => {
|
||||
cmd_send_http_request: (payload, db) => {
|
||||
const requestId = str(payload, "requestId");
|
||||
if (requestId == null) throw new Error("cmd_send_http_request needs a requestId");
|
||||
return sendHttpRequest(
|
||||
db,
|
||||
plugins,
|
||||
requestId,
|
||||
str(payload, "environmentId"),
|
||||
str(payload, "cookieJarId"),
|
||||
);
|
||||
return sendHttpRequest(db, requestId, str(payload, "environmentId"), str(payload, "cookieJarId"));
|
||||
},
|
||||
|
||||
/* -------------------------------- app ---------------------------------- */
|
||||
@@ -184,67 +146,22 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
* Both of these are polled once a second until they answer with something, so
|
||||
* an empty list is not a quiet no — it is a poll that never stops.
|
||||
*
|
||||
* Both now answer from the plugins actually loaded in the sandbox, which is
|
||||
* the only answer that stays true: an authentication method in the picker
|
||||
* that no loaded plugin can apply would be a promise this host cannot keep,
|
||||
* and a template function offered in the autocomplete that nothing can
|
||||
* evaluate would be worse than none.
|
||||
* The auth list names what Yaak actually offers, so the picker tells the
|
||||
* truth about the product even though the form behind each entry stays empty
|
||||
* until plugins run here. Template functions get the opposite treatment: one
|
||||
* provider contributing no functions. That settles the poll while putting
|
||||
* nothing in the autocomplete, which is the honest answer — a function the
|
||||
* user could insert but nothing could evaluate would be worse than none.
|
||||
*/
|
||||
async cmd_get_http_authentication_summaries(_payload, _db, plugins) {
|
||||
return plugins.httpAuthenticationSummaries();
|
||||
async cmd_get_http_authentication_summaries() {
|
||||
return HTTP_AUTHENTICATION_SUMMARIES;
|
||||
},
|
||||
async cmd_template_function_summaries(_payload, _db, plugins) {
|
||||
return plugins.templateFunctionSummaries();
|
||||
async cmd_template_function_summaries() {
|
||||
return [{ pluginRefId: "web", functions: [] }];
|
||||
},
|
||||
|
||||
async cmd_get_http_authentication_config(payload, _db, plugins) {
|
||||
const authName = str(payload, "authName");
|
||||
const config =
|
||||
authName == null
|
||||
? null
|
||||
: await plugins.httpAuthenticationConfig(authName, values(payload), contextId(payload));
|
||||
return config ?? { args: [], actions: [], pluginRefId: "web" };
|
||||
},
|
||||
|
||||
async cmd_template_function_config(payload, _db, plugins) {
|
||||
const name = str(payload, "functionName") ?? str(payload, "name");
|
||||
if (name == null) return null;
|
||||
return plugins.templateFunctionConfig(name, values(payload), contextId(payload));
|
||||
},
|
||||
|
||||
async cmd_call_http_authentication_action(payload, _db, plugins) {
|
||||
const authName = str(payload, "authName");
|
||||
if (authName == null) return null;
|
||||
const index = payload.actionIndex;
|
||||
await plugins.callHttpAuthenticationAction(
|
||||
authName,
|
||||
typeof index === "number" ? index : 0,
|
||||
values(payload),
|
||||
contextId(payload),
|
||||
);
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Turn a pasted cURL command into a request.
|
||||
*
|
||||
* Routed through the same importer the desktop uses, in the sandbox, which
|
||||
* is why this is a handler and no longer a refusal. The reshaping afterwards
|
||||
* matches `cmd_curl_to_request` in crates/yaak-commands: the importer names a
|
||||
* workspace of its own invention and mints an id, and both belong to the
|
||||
* caller instead.
|
||||
*/
|
||||
async cmd_curl_to_request(payload, _db, plugins) {
|
||||
const resources = await plugins.import(text(payload, "command"));
|
||||
const imported = resources?.httpRequests?.[0];
|
||||
if (imported == null) {
|
||||
throw new Error("Failed to import cURL command");
|
||||
}
|
||||
return {
|
||||
...imported,
|
||||
id: "",
|
||||
workspaceId: str(payload, "workspaceId") ?? imported.workspaceId,
|
||||
} as HttpRequest;
|
||||
async cmd_get_http_authentication_config() {
|
||||
return { args: [], pluginRefId: "web" };
|
||||
},
|
||||
|
||||
async cmd_format_json(payload) {
|
||||
@@ -259,19 +176,13 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve variables and call template functions, in the engine, exactly as
|
||||
* `cmd_render_template` does on the desktop. The functions come back out to
|
||||
* the sandbox as the render reaches them — see `templateBridge` in worker.ts.
|
||||
* Rendering resolves variables and calls template functions, and the
|
||||
* functions live in plugins. Handing the template back unrendered is what the
|
||||
* preview then shows — the raw `${[...]}`, which is at least the thing the
|
||||
* user typed rather than a wrong value.
|
||||
*/
|
||||
async cmd_render_template(payload, db) {
|
||||
const workspaceId = str(payload, "workspaceId");
|
||||
if (workspaceId == null) return text(payload, "template");
|
||||
return db.renderTemplate({
|
||||
template: text(payload, "template"),
|
||||
workspaceId,
|
||||
environmentId: str(payload, "environmentId"),
|
||||
ignoreError: payload.ignoreError === true,
|
||||
});
|
||||
async cmd_render_template(payload) {
|
||||
return text(payload, "template");
|
||||
},
|
||||
|
||||
/* ------------------------------- bodies -------------------------------- */
|
||||
@@ -313,6 +224,21 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The auth methods Yaak ships as plugins today (plugins/auth-*). Listed so the
|
||||
* picker is truthful about the product; choosing one currently yields an empty
|
||||
* config form, because the plugin that defines the form isn't running.
|
||||
*/
|
||||
const HTTP_AUTHENTICATION_SUMMARIES = [
|
||||
{ name: "apikey", label: "API Key", shortLabel: "API Key" },
|
||||
{ name: "aws", label: "AWS SigV4", shortLabel: "AWS" },
|
||||
{ name: "basic", label: "Basic Auth", shortLabel: "Basic" },
|
||||
{ name: "bearer", label: "Bearer Token", shortLabel: "Bearer" },
|
||||
{ name: "jwt", label: "JWT Bearer", shortLabel: "JWT" },
|
||||
{ name: "ntlm", label: "NTLM", shortLabel: "NTLM" },
|
||||
{ name: "oauth1", label: "OAuth 1.0", shortLabel: "OAuth 1" },
|
||||
{ name: "oauth2", label: "OAuth 2.0", shortLabel: "OAuth 2" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Commands this host declines, each with the reason a user would need.
|
||||
@@ -327,6 +253,7 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
|
||||
// ones nothing stores, used for GraphQL introspection — take the same road but
|
||||
// return the body inline; not wired yet.
|
||||
cmd_send_ephemeral_request: ["Sending unsaved requests isn't available in the browser yet", null],
|
||||
cmd_curl_to_request: ["Importing from cURL needs a plugin, which this host doesn't run", null],
|
||||
|
||||
// Protocols that need a real socket.
|
||||
cmd_grpc_reflect: ["gRPC isn't available in the browser", "grpc"],
|
||||
@@ -368,12 +295,14 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
|
||||
cmd_plugins_uninstall: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_updates: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_update_all: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_template_function_config: ["Template functions come from plugins, which this host doesn't run", "plugins"],
|
||||
cmd_template_tokens_to_string: ["Template functions come from plugins, which this host doesn't run", "plugins"],
|
||||
cmd_call_http_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_websocket_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_grpc_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_workspace_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_folder_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_http_authentication_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
|
||||
cmd_send_feedback: ["Feedback goes through the desktop app for now", null],
|
||||
};
|
||||
@@ -400,10 +329,9 @@ export async function runCommand(
|
||||
cmd: string,
|
||||
payload: RpcPayload,
|
||||
db: WorkerConnection,
|
||||
plugins: WebPlugins,
|
||||
): Promise<unknown> {
|
||||
const handler = HANDLERS[cmd as AppCmd];
|
||||
if (handler != null) return handler(payload, db, plugins);
|
||||
if (handler != null) return handler(payload, db);
|
||||
|
||||
const declined = DECLINED[cmd as AppCmd];
|
||||
if (declined != null) throw unsupported(cmd, declined[0], declined[1]);
|
||||
|
||||
@@ -50,9 +50,6 @@ export class WorkerConnection {
|
||||
/** True once the worker has said anything at all. */
|
||||
private heard = false;
|
||||
|
||||
/** Unset until the sandbox is up; a render before then gets a refusal. */
|
||||
private templateFunctions: ((name: string, args: string) => Promise<string>) | null = null;
|
||||
|
||||
constructor() {
|
||||
// Both are required and neither is faked. Without a shared worker every
|
||||
// tab would need its own SQLite over the same pages; without Web Locks
|
||||
@@ -150,9 +147,6 @@ export class WorkerConnection {
|
||||
case "event":
|
||||
this.deliver(message.event, message.payload);
|
||||
return;
|
||||
case "template_function":
|
||||
void this.runTemplateFunction(message.id, message.name, message.args);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,34 +159,6 @@ export class WorkerConnection {
|
||||
});
|
||||
}
|
||||
|
||||
setTemplateFunctionHandler(handler: (name: string, args: string) => Promise<string>): void {
|
||||
this.templateFunctions = handler;
|
||||
}
|
||||
|
||||
private async runTemplateFunction(id: number, name: string, args: string): Promise<void> {
|
||||
if (this.templateFunctions == null) {
|
||||
this.post({
|
||||
type: "template_function_result",
|
||||
id,
|
||||
error: `The template function \`${name}\` needs a plugin, and none are loaded yet`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.post({
|
||||
type: "template_function_result",
|
||||
id,
|
||||
value: await this.templateFunctions(name, args),
|
||||
});
|
||||
} catch (err) {
|
||||
this.post({
|
||||
type: "template_function_result",
|
||||
id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
rpc<T>(cmd: string, payload: unknown): Promise<T> {
|
||||
return this.request<T>((id) => ({ type: "rpc", id, cmd, payload, label: this.label }));
|
||||
}
|
||||
@@ -202,11 +168,6 @@ export class WorkerConnection {
|
||||
return this.request<T>((id) => ({ type: "prepare_http_send", id, payload }));
|
||||
}
|
||||
|
||||
/** See `render_template` in crates/yaak-wasm. */
|
||||
renderTemplate(payload: unknown): Promise<string> {
|
||||
return this.request<string>((id) => ({ type: "render_template", id, payload }));
|
||||
}
|
||||
|
||||
async blobGet(blobId: string): Promise<Uint8Array<ArrayBuffer> | null> {
|
||||
const buf = await this.request<ArrayBuffer | null>((id) => ({ type: "blob_get", id, blobId }));
|
||||
return buf == null ? null : new Uint8Array(buf);
|
||||
|
||||
@@ -28,7 +28,6 @@ import type {
|
||||
import { commandSupport, runCommand } from "./commands";
|
||||
import { WorkerConnection } from "./connection";
|
||||
import { unsupported } from "./errors";
|
||||
import { WebPlugins } from "./plugins";
|
||||
import { requestPersistence } from "./storage";
|
||||
|
||||
/** What this host can do, reported honestly. */
|
||||
@@ -61,9 +60,7 @@ function capabilitiesFor(): PlatformCapabilities {
|
||||
// The browser already zooms the page, on the same keys, and remembers it
|
||||
// per site. The app stays out of the way.
|
||||
interfaceZoom: false,
|
||||
// Plugins run in a QuickJS sandbox, but only the bundled set: there is no
|
||||
// installing them, so the plugin manager stays unavailable and says so.
|
||||
plugins: true,
|
||||
plugins: false,
|
||||
encryption: false,
|
||||
updater: false,
|
||||
// Reading needs a permission prompt at first paint, which is a bad ask for
|
||||
@@ -163,12 +160,8 @@ function createWindow(db: WorkerConnection): PlatformWindow {
|
||||
|
||||
export function createWebPlatform(): Platform {
|
||||
const db = new WorkerConnection();
|
||||
const plugins = new WebPlugins(db);
|
||||
const capabilities = capabilitiesFor();
|
||||
|
||||
// Registered before anything can render, not inside the first send.
|
||||
db.setTemplateFunctionHandler((name, args) => plugins.callTemplateFunction(name, args));
|
||||
|
||||
// Without this, IndexedDB is best-effort storage and a browser reclaiming
|
||||
// space may drop someone's workspaces. Asking is all we can do, and there is
|
||||
// nothing useful to do about a refusal.
|
||||
@@ -238,7 +231,7 @@ export function createWebPlatform(): Platform {
|
||||
// `plugin:` commands are Tauri host plugins, not engine commands, and
|
||||
// never reached the router even on the desktop.
|
||||
if (cmd.startsWith("plugin:")) return hostPluginCommand<T>(cmd, payload);
|
||||
return runCommand(cmd, payload ?? {}, db, plugins) as Promise<T>;
|
||||
return runCommand(cmd, payload ?? {}, db) as Promise<T>;
|
||||
},
|
||||
|
||||
async rpcStream<T, M>(
|
||||
@@ -251,7 +244,7 @@ export function createWebPlatform(): Platform {
|
||||
const streamId = crypto.randomUUID();
|
||||
const unlisten = db.listen(`stream_${streamId}`, (p) => onMessage(p as M));
|
||||
try {
|
||||
const result = (await runCommand(cmd, { ...payload, streamId }, db, plugins)) as T;
|
||||
const result = (await runCommand(cmd, { ...payload, streamId }, db)) as T;
|
||||
return { result, unlisten };
|
||||
} catch (err) {
|
||||
unlisten();
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
/**
|
||||
* Keeps a sandbox, loads the bundled plugins into it, routes by what each one
|
||||
* contributes, and answers the `ctx` calls they make. `hostRequest` below is
|
||||
* the whole of what a plugin can do to the world here.
|
||||
*/
|
||||
|
||||
import { PluginSandbox, type PluginSummary } from "@yaakapp-internal/plugin-sandbox";
|
||||
import type {
|
||||
GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse,
|
||||
GetTemplateFunctionConfigResponse,
|
||||
GetTemplateFunctionSummaryResponse,
|
||||
ImportResources,
|
||||
InternalEventPayload,
|
||||
JsonPrimitive,
|
||||
PluginContext,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
import type { WorkerConnection } from "./connection";
|
||||
import { SANDBOX_PLUGINS } from "./sandboxPlugins.generated";
|
||||
|
||||
type KeyValueRequest = { key: string };
|
||||
|
||||
export interface AppliedAuthentication {
|
||||
setHeaders?: { name: string; value: string }[] | null;
|
||||
setQueryParameters?: { name: string; value: string }[] | null;
|
||||
}
|
||||
|
||||
export class WebPlugins {
|
||||
private readonly db: WorkerConnection;
|
||||
private sandbox: PluginSandbox | null = null;
|
||||
private loading: Promise<void> | null = null;
|
||||
|
||||
private readonly byTemplateFunction = new Map<string, string>();
|
||||
private readonly byAuthName = new Map<string, string>();
|
||||
private readonly importers: string[] = [];
|
||||
private readonly summaries = new Map<string, PluginSummary>();
|
||||
|
||||
constructor(db: WorkerConnection) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from every entry point rather than at construction, so a session
|
||||
* that never touches a plugin never pays for QuickJS.
|
||||
*/
|
||||
ready(): Promise<void> {
|
||||
this.loading ??= this.start();
|
||||
return this.loading;
|
||||
}
|
||||
|
||||
private async start(): Promise<void> {
|
||||
const sandbox = new PluginSandbox({
|
||||
onHostRequest: (envelope) => this.hostRequest(envelope),
|
||||
onLog: ({ pluginRefId, level, message }) => {
|
||||
// Prefixed, or a plugin's console output blames the app's own code.
|
||||
const write = level === "error" ? console.error : console.log;
|
||||
write(`[plugin ${pluginRefId}] ${message}`);
|
||||
},
|
||||
});
|
||||
this.sandbox = sandbox;
|
||||
|
||||
await Promise.all(
|
||||
SANDBOX_PLUGINS.map(async ({ name, source }) => {
|
||||
try {
|
||||
const summary = await sandbox.load(name, source);
|
||||
this.summaries.set(name, summary);
|
||||
for (const fn of summary.templateFunctions) this.byTemplateFunction.set(fn, name);
|
||||
if (summary.authentication != null) this.byAuthName.set(summary.authentication, name);
|
||||
if (summary.importer) this.importers.push(name);
|
||||
} catch (err) {
|
||||
// One bad bundle should cost its own features and nothing else.
|
||||
console.error(`Failed to load plugin \`${name}\``, err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------ what exists ------------------------------ */
|
||||
|
||||
async templateFunctionSummaries(): Promise<GetTemplateFunctionSummaryResponse[]> {
|
||||
await this.ready();
|
||||
return this.gather("get_template_function_summary_request", this.summaries.keys());
|
||||
}
|
||||
|
||||
async httpAuthenticationSummaries(): Promise<GetHttpAuthenticationSummaryResponse[]> {
|
||||
await this.ready();
|
||||
return this.gather("get_http_authentication_summary_request", this.byAuthName.values());
|
||||
}
|
||||
|
||||
/** One broken plugin must not empty the picker for the others. */
|
||||
private async gather<T>(type: string, ids: Iterable<string>): Promise<T[]> {
|
||||
const replies = await Promise.all(
|
||||
Array.from(ids).map(async (id): Promise<{ type: string } | null> => {
|
||||
try {
|
||||
return await this.dispatch(id, { type } as InternalEventPayload);
|
||||
} catch (err) {
|
||||
console.error(`Plugin \`${id}\` failed to answer \`${type}\``, err);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return replies.filter((r) => r != null && r.type !== "empty_response") as T[];
|
||||
}
|
||||
|
||||
/* -------------------------------- calling -------------------------------- */
|
||||
|
||||
async templateFunctionConfig(
|
||||
name: string,
|
||||
values: Record<string, JsonPrimitive>,
|
||||
contextId: string,
|
||||
): Promise<GetTemplateFunctionConfigResponse | null> {
|
||||
await this.ready();
|
||||
const id = this.byTemplateFunction.get(name);
|
||||
if (id == null) return null;
|
||||
return this.dispatch(id, {
|
||||
type: "get_template_function_config_request",
|
||||
contextId,
|
||||
name,
|
||||
values,
|
||||
} as InternalEventPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the engine's render calls back into. A function nothing provides is a
|
||||
* throw naming it, not an empty string: a request sent with a silently blank
|
||||
* token is worse than one that refuses to be sent.
|
||||
*/
|
||||
async callTemplateFunction(name: string, argsJson: string): Promise<string> {
|
||||
await this.ready();
|
||||
const id = this.byTemplateFunction.get(name);
|
||||
if (id == null) {
|
||||
throw new Error(`No plugin provides the template function \`${name}\``);
|
||||
}
|
||||
|
||||
const values = JSON.parse(argsJson) as Record<string, JsonPrimitive>;
|
||||
const reply = await this.dispatch<{ value: string | null; error?: string | null }>(id, {
|
||||
type: "call_template_function_request",
|
||||
name,
|
||||
args: { purpose: "send", values },
|
||||
} as InternalEventPayload);
|
||||
|
||||
if (reply.error) throw new Error(reply.error);
|
||||
return reply.value ?? "";
|
||||
}
|
||||
|
||||
async httpAuthenticationConfig(
|
||||
authName: string,
|
||||
values: Record<string, JsonPrimitive>,
|
||||
contextId: string,
|
||||
): Promise<GetHttpAuthenticationConfigResponse | null> {
|
||||
await this.ready();
|
||||
const id = this.byAuthName.get(authName);
|
||||
if (id == null) return null;
|
||||
return this.dispatch(id, {
|
||||
type: "get_http_authentication_config_request",
|
||||
contextId,
|
||||
values,
|
||||
} as InternalEventPayload);
|
||||
}
|
||||
|
||||
async callHttpAuthenticationAction(
|
||||
authName: string,
|
||||
index: number,
|
||||
values: Record<string, JsonPrimitive>,
|
||||
contextId: string,
|
||||
): Promise<void> {
|
||||
await this.ready();
|
||||
const id = this.byAuthName.get(authName);
|
||||
if (id == null) throw new Error(`No plugin provides \`${authName}\` authentication`);
|
||||
await this.dispatch(id, {
|
||||
type: "call_http_authentication_action_request",
|
||||
index,
|
||||
pluginRefId: id,
|
||||
args: { contextId, values },
|
||||
} as InternalEventPayload);
|
||||
}
|
||||
|
||||
|
||||
async applyHttpAuthentication(
|
||||
authName: string,
|
||||
request: {
|
||||
contextId: string;
|
||||
values: Record<string, JsonPrimitive>;
|
||||
method: string;
|
||||
url: string;
|
||||
headers: { name: string; value: string }[];
|
||||
body: string | null;
|
||||
},
|
||||
): Promise<AppliedAuthentication> {
|
||||
await this.ready();
|
||||
const id = this.byAuthName.get(authName);
|
||||
if (id == null) {
|
||||
throw new Error(
|
||||
`This request uses ${authName} authentication, which no plugin in the browser provides`,
|
||||
);
|
||||
}
|
||||
return this.dispatch<AppliedAuthentication>(id, {
|
||||
type: "call_http_authentication_request",
|
||||
...request,
|
||||
} as InternalEventPayload);
|
||||
}
|
||||
|
||||
/** First importer that recognizes the text wins, as `import_data` decides too. */
|
||||
async import(content: string): Promise<ImportResources | null> {
|
||||
await this.ready();
|
||||
for (const id of this.importers) {
|
||||
try {
|
||||
const reply = await this.dispatch<{ resources?: ImportResources }>(id, {
|
||||
type: "import_request",
|
||||
content,
|
||||
} as InternalEventPayload);
|
||||
if (reply.type === "import_response" && reply.resources != null) return reply.resources;
|
||||
} catch (err) {
|
||||
console.error(`Importer \`${id}\` failed`, err);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ------------------------------- internals ------------------------------- */
|
||||
|
||||
private async dispatch<T>(
|
||||
pluginRefId: string,
|
||||
payload: InternalEventPayload,
|
||||
): Promise<T & { type: string }> {
|
||||
if (this.sandbox == null) throw new Error("The plugin sandbox is not running");
|
||||
return this.sandbox.dispatch<T>(pluginRefId, this.context(), payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* `label` names a desktop window, so it stays null and the calls needing one
|
||||
* refuse rather than guess which request the user is looking at.
|
||||
*/
|
||||
private context(): PluginContext {
|
||||
return { id: "web", label: null, workspaceId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every addition here is a capability decision, which is why they are written
|
||||
* out one at a time instead of forwarded wholesale.
|
||||
*/
|
||||
private async hostRequest(envelope: string): Promise<string> {
|
||||
const { pluginRefId, payload } = JSON.parse(envelope) as {
|
||||
pluginRefId: string;
|
||||
context: PluginContext;
|
||||
payload: InternalEventPayload;
|
||||
};
|
||||
|
||||
const reply = async (): Promise<InternalEventPayload> => {
|
||||
switch (payload.type) {
|
||||
case "get_key_value_request": {
|
||||
const value = await this.db.rpc<string | null>("web_plugin_kv_get", {
|
||||
pluginName: pluginRefId,
|
||||
key: (payload as unknown as KeyValueRequest).key,
|
||||
});
|
||||
return { type: "get_key_value_response", value } as InternalEventPayload;
|
||||
}
|
||||
case "set_key_value_request": {
|
||||
const { key, value } = payload as unknown as { key: string; value: string };
|
||||
await this.db.rpc("web_plugin_kv_set", {
|
||||
pluginName: pluginRefId,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
return { type: "set_key_value_response" } as InternalEventPayload;
|
||||
}
|
||||
case "delete_key_value_request": {
|
||||
const deleted = await this.db.rpc<boolean>("web_plugin_kv_delete", {
|
||||
pluginName: pluginRefId,
|
||||
key: (payload as unknown as KeyValueRequest).key,
|
||||
});
|
||||
return { type: "delete_key_value_response", deleted } as InternalEventPayload;
|
||||
}
|
||||
|
||||
case "show_toast_request": {
|
||||
const { type: _type, ...toast } = payload;
|
||||
this.db.deliver("show_toast", toast);
|
||||
return { type: "empty_response" };
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`\`${payload.type}\` isn't something a plugin can do when Yaak runs in a browser yet`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
return JSON.stringify(await reply());
|
||||
} catch (err) {
|
||||
return JSON.stringify({
|
||||
type: "error_response",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,15 +16,6 @@ export type ToWorker =
|
||||
* async in the engine (rendering is), where every `rpc` command is not.
|
||||
*/
|
||||
| { type: "prepare_http_send"; id: number; payload: unknown }
|
||||
/** Async for the same reason `prepare_http_send` is: it can call a plugin. */
|
||||
| { type: "render_template"; id: number; payload: unknown }
|
||||
/** The tab's answer to a `template_function` call. */
|
||||
| {
|
||||
type: "template_function_result";
|
||||
id: number;
|
||||
value?: string;
|
||||
error?: string;
|
||||
}
|
||||
| { type: "blob_get"; id: number; blobId: string }
|
||||
| { type: "blob_put"; id: number; blobId: string; bytes: ArrayBuffer }
|
||||
| { type: "blob_delete"; id: number; blobId: string }
|
||||
@@ -47,9 +38,7 @@ export type FromWorker =
|
||||
| { type: "result"; id: number; result: unknown }
|
||||
| { type: "error"; id: number; message: string }
|
||||
/** A backend event for the app — today only `model_writes`. Sent to every port. */
|
||||
| { type: "event"; event: string; payload: unknown }
|
||||
/** The one message that runs the other way: the engine asking for a plugin. */
|
||||
| { type: "template_function"; id: number; name: string; args: string };
|
||||
| { type: "event"; event: string; payload: unknown };
|
||||
|
||||
/** What the worker registers itself under. Tabs on one origin share it. */
|
||||
export const WORKER_NAME = "yaak-db";
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -33,8 +33,7 @@ import type {
|
||||
} from "@yaakapp-internal/models";
|
||||
import type { Frame, SendRequest } from "@yaakapp-internal/web";
|
||||
import type { WorkerConnection } from "./connection";
|
||||
import type { WebPlugins } from "./plugins";
|
||||
import { readFrames, serverIdentity, serverSendUrl } from "./server";
|
||||
import { serverIdentity, serverSendUrl, readFrames } from "./server";
|
||||
|
||||
/* -------------------------------- shapes --------------------------------- */
|
||||
|
||||
@@ -52,59 +51,11 @@ type ResponsePatch = Partial<HttpResponse>;
|
||||
/** What `prepare_http_send` (crates/yaak-wasm) hands back. */
|
||||
interface PreparedHttpSend {
|
||||
request: HttpRequest;
|
||||
/** Hashed id of the model the auth came from; plugins key stored state on it. */
|
||||
authContextId: string;
|
||||
settings: HttpSendSettings;
|
||||
settingEvents: HttpResponseEventData[];
|
||||
cookieJar: CookieJar | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The desktop applies auth to the sendable request; here the server builds that,
|
||||
* so the plugin's answer goes onto the model and the server folds it in. Same
|
||||
* bytes for a method that sets a header, which is every one that runs here.
|
||||
*
|
||||
* Not the same for one that *signs*, since the plugin sees the request before
|
||||
* the server assembles it. AWS SigV4 and OAuth 1.0 are refused rather than
|
||||
* mis-signed; see the sandbox README.
|
||||
*/
|
||||
async function applyAuthentication(
|
||||
plugins: WebPlugins,
|
||||
prepared: PreparedHttpSend,
|
||||
): Promise<HttpRequest> {
|
||||
const { request } = prepared;
|
||||
const authType = request.authenticationType;
|
||||
const disabled = request.authentication?.disabled === true;
|
||||
if (authType == null || authType === "none" || disabled) return request;
|
||||
|
||||
const applied = await plugins.applyHttpAuthentication(authType, {
|
||||
contextId: prepared.authContextId,
|
||||
values: request.authentication as Record<string, never>,
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: request.headers.filter((h) => h.enabled !== false),
|
||||
// Only signing schemes hash the body, and those are already refused.
|
||||
body: null,
|
||||
});
|
||||
|
||||
const headers = [...request.headers];
|
||||
for (const header of applied.setHeaders ?? []) {
|
||||
// Replace-or-append, case-insensitively, matching `insert_header` in
|
||||
// crates/yaak-http.
|
||||
const at = headers.findIndex((h) => h.name.toLowerCase() === header.name.toLowerCase());
|
||||
const entry = { name: header.name, value: header.value, enabled: true };
|
||||
if (at >= 0) headers[at] = { ...headers[at], ...entry };
|
||||
else headers.push(entry);
|
||||
}
|
||||
|
||||
const urlParameters = [...request.urlParameters];
|
||||
for (const param of applied.setQueryParameters ?? []) {
|
||||
urlParameters.push({ name: param.name, value: param.value, enabled: true });
|
||||
}
|
||||
|
||||
return { ...request, headers, urlParameters };
|
||||
}
|
||||
|
||||
/** The desktop writes progress at most this often while a body streams in. */
|
||||
const PROGRESS_INTERVAL_MS = 100;
|
||||
|
||||
@@ -112,7 +63,6 @@ const PROGRESS_INTERVAL_MS = 100;
|
||||
|
||||
export async function sendHttpRequest(
|
||||
db: WorkerConnection,
|
||||
plugins: WebPlugins,
|
||||
requestId: string,
|
||||
environmentId: string | null,
|
||||
cookieJarId: string | null,
|
||||
@@ -128,7 +78,7 @@ export async function sendHttpRequest(
|
||||
const unlistenCancel = db.listen(`cancel_http_response_${response.id}`, () => cancel.abort());
|
||||
|
||||
try {
|
||||
await runSend(db, plugins, response, requestId, environmentId, cookieJarId, cancel.signal);
|
||||
await runSend(db, response, requestId, environmentId, cookieJarId, cancel.signal);
|
||||
} catch (err) {
|
||||
const message = cancel.signal.aborted ? "Request canceled" : errorMessage(err);
|
||||
await response.finish({ error: message });
|
||||
@@ -140,7 +90,6 @@ export async function sendHttpRequest(
|
||||
|
||||
async function runSend(
|
||||
db: WorkerConnection,
|
||||
plugins: WebPlugins,
|
||||
response: ResponseWriter,
|
||||
requestId: string,
|
||||
environmentId: string | null,
|
||||
@@ -152,8 +101,7 @@ async function runSend(
|
||||
environmentId,
|
||||
cookieJarId,
|
||||
});
|
||||
const request = await applyAuthentication(plugins, prepared);
|
||||
await response.patch({ url: request.url });
|
||||
await response.patch({ url: prepared.request.url });
|
||||
|
||||
// The first line of the timeline says what did the sending and where. A
|
||||
// request through a proxy shows a different origin to the server than the
|
||||
@@ -163,7 +111,7 @@ async function runSend(
|
||||
timeline.push(prepared.settingEvents);
|
||||
|
||||
const body: SendRequest = {
|
||||
request,
|
||||
request: prepared.request,
|
||||
settings: prepared.settings,
|
||||
cookies: prepared.cookieJar?.cookies ?? null,
|
||||
};
|
||||
|
||||
@@ -108,36 +108,12 @@ function bootOnce(): Promise<void> {
|
||||
return booted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendering happens here; the functions it calls live in a sandbox the tab
|
||||
* owns. Asked of the port that started the render, not every port, because
|
||||
* only that tab is waiting and only its sandbox has those plugins.
|
||||
*/
|
||||
const pendingTemplateFunctions = new Map<number, (result: string | Error) => void>();
|
||||
let nextTemplateFunctionId = 1;
|
||||
|
||||
function templateBridge(port: MessagePort): (name: string, args: string) => Promise<string> {
|
||||
return (name, args) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const id = nextTemplateFunctionId++;
|
||||
pendingTemplateFunctions.set(id, (r) => (r instanceof Error ? reject(r) : resolve(r)));
|
||||
send(port, { type: "template_function", id, name, args });
|
||||
});
|
||||
}
|
||||
|
||||
async function handle(port: MessagePort, message: ToWorker): Promise<void> {
|
||||
if (message.type === "goodbye") {
|
||||
ports.delete(port);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "template_function_result") {
|
||||
const settle = pendingTemplateFunctions.get(message.id);
|
||||
pendingTemplateFunctions.delete(message.id);
|
||||
settle?.(message.error != null ? new Error(message.error) : (message.value ?? ""));
|
||||
return;
|
||||
}
|
||||
|
||||
// Every command waits for boot rather than the tab having to. Tabs post
|
||||
// the moment they load; the port queues; this drains once the DB is open.
|
||||
try {
|
||||
@@ -146,8 +122,7 @@ async function handle(port: MessagePort, message: ToWorker): Promise<void> {
|
||||
send(port, { type: "error", id: message.id, message: bootError ?? "Database failed to open" });
|
||||
return;
|
||||
}
|
||||
const { rpc, blob_get, blob_put, blob_delete, prepare_http_send, render_template } =
|
||||
engine!;
|
||||
const { rpc, blob_get, blob_put, blob_delete, prepare_http_send } = engine!;
|
||||
|
||||
try {
|
||||
switch (message.type) {
|
||||
@@ -168,15 +143,10 @@ async function handle(port: MessagePort, message: ToWorker): Promise<void> {
|
||||
return;
|
||||
}
|
||||
case "prepare_http_send": {
|
||||
const prepared = await prepare_http_send(message.payload, templateBridge(port));
|
||||
const prepared = await prepare_http_send(message.payload);
|
||||
send(port, { type: "result", id: message.id, result: prepared });
|
||||
return;
|
||||
}
|
||||
case "render_template": {
|
||||
const rendered = await render_template(message.payload, templateBridge(port));
|
||||
send(port, { type: "result", id: message.id, result: rendered });
|
||||
return;
|
||||
}
|
||||
case "blob_get": {
|
||||
const bytes = blob_get(message.blobId);
|
||||
if (bytes == null) {
|
||||
|
||||
+16
-1
@@ -109,6 +109,7 @@ export type Folder = {
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type GraphQlIntrospection = {
|
||||
@@ -213,6 +214,7 @@ export type HttpRequest = {
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
@@ -314,8 +316,12 @@ export type HttpUrlParameter = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||
|
||||
export type KeyValue = {
|
||||
@@ -378,6 +384,7 @@ export type Settings = {
|
||||
themeLight: string;
|
||||
updateChannel: string;
|
||||
hideLicenseBadge: boolean;
|
||||
promptFeedback: boolean;
|
||||
autoupdate: boolean;
|
||||
autoDownloadUpdates: boolean;
|
||||
checkNotifications: boolean;
|
||||
@@ -428,7 +435,14 @@ export type WebsocketEvent = {
|
||||
};
|
||||
|
||||
export type WebsocketEventType =
|
||||
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
|
||||
| "binary"
|
||||
| "close"
|
||||
| "error"
|
||||
| "frame"
|
||||
| "open"
|
||||
| "ping"
|
||||
| "pong"
|
||||
| "text";
|
||||
|
||||
export type WebsocketRequest = {
|
||||
model: "websocket_request";
|
||||
@@ -473,6 +487,7 @@ export type Workspace = {
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
settingHttpVersion: HttpVersion;
|
||||
};
|
||||
|
||||
export type WorkspaceMeta = {
|
||||
|
||||
@@ -2,36 +2,72 @@ import console from "node:console";
|
||||
import { type Stats, statSync, watch } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
CallPromptFormDynamicArgs,
|
||||
Context,
|
||||
DynamicPromptFormArg,
|
||||
PluginDefinition,
|
||||
} from "@yaakapp/api";
|
||||
import {
|
||||
createPluginContext,
|
||||
type PluginTransport,
|
||||
} from "@yaakapp-internal/lib/pluginContext";
|
||||
import {
|
||||
applyDynamicFormInput,
|
||||
migrateTemplateFunctionSelectOptions,
|
||||
stripDynamicCallbacks,
|
||||
} from "@yaakapp-internal/lib/pluginForms";
|
||||
import {
|
||||
applyFormInputDefaults,
|
||||
validateTemplateFunctionArgs,
|
||||
} from "@yaakapp-internal/lib/templateFunction";
|
||||
import type {
|
||||
BootRequest,
|
||||
DeleteKeyValueResponse,
|
||||
DeleteModelResponse,
|
||||
FindHttpResponsesResponse,
|
||||
Folder,
|
||||
FormInput,
|
||||
GetCookieValueRequest,
|
||||
GetCookieValueResponse,
|
||||
GetHttpRequestByIdResponse,
|
||||
GetHttpResponseBodyInfoResponse,
|
||||
GetKeyValueResponse,
|
||||
GrpcRequestAction,
|
||||
HttpAuthenticationAction,
|
||||
HttpRequest,
|
||||
HttpRequestAction,
|
||||
HttpResponse,
|
||||
ImportResources,
|
||||
InternalEvent,
|
||||
InternalEventPayload,
|
||||
ListCookieNamesResponse,
|
||||
ListFoldersResponse,
|
||||
ListHttpRequestsRequest,
|
||||
ListHttpRequestsResponse,
|
||||
ListOpenWorkspacesResponse,
|
||||
PluginContext,
|
||||
PromptFormResponse,
|
||||
PromptTextResponse,
|
||||
ReadHttpResponseBodyChunkResponse,
|
||||
RenderGrpcRequestResponse,
|
||||
RenderHttpRequestResponse,
|
||||
SendHttpRequestResponse,
|
||||
TemplateFunction,
|
||||
TemplateRenderRequest,
|
||||
TemplateRenderResponse,
|
||||
UpsertModelResponse,
|
||||
WindowInfoResponse,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
import { applyDynamicFormInput } from "./common";
|
||||
import { EventChannel } from "./EventChannel";
|
||||
import { migrateTemplateFunctionSelectOptions } from "./migrations";
|
||||
import { createResponseBody, decodeBase64Chunk } from "./responseBody";
|
||||
|
||||
/**
|
||||
* A response as a plugin should see it.
|
||||
*
|
||||
* The host still puts `bodyPath` on the wire for its own callers, but it names
|
||||
* a file on the host's disk — meaningless to a plugin, absent once bodies move
|
||||
* off the filesystem, and impossible in a browser. Plugins address bodies by
|
||||
* response id, so drop it here rather than let one grow a dependency on it.
|
||||
*/
|
||||
function forPlugin(httpResponse: HttpResponse): HttpResponse {
|
||||
const { bodyPath: _bodyPath, ...rest } = httpResponse as HttpResponse & {
|
||||
bodyPath?: string | null;
|
||||
};
|
||||
return rest;
|
||||
}
|
||||
|
||||
export interface PluginWorkerData {
|
||||
bootRequest: BootRequest;
|
||||
@@ -593,64 +629,444 @@ export class PluginInstance {
|
||||
this.#sendEvent(eventToSend);
|
||||
}
|
||||
|
||||
/**
|
||||
* How a plugin reaches the app from this runtime.
|
||||
*
|
||||
* Every request is an event whose reply is matched by id. This runtime can
|
||||
* hold a conversation open, so it supplies `stream` and `form`: a window
|
||||
* reports navigation until it closes, and a prompt form re-renders as values
|
||||
* change. `ctx` itself is built from these in @yaakapp-internal/lib, the same
|
||||
* way the sandbox runtime builds it.
|
||||
*/
|
||||
#transport: PluginTransport = {
|
||||
request: (context, payload) => this.#sendForReply(context, payload),
|
||||
#newCtx(context: PluginContext): Context {
|
||||
/** Read a body the host has stored, a chunk at a time, following it if it is still arriving. */
|
||||
const storedBody = async (responseId: string) => {
|
||||
const bodyInfo = () =>
|
||||
this.#sendForReply<GetHttpResponseBodyInfoResponse>(context, {
|
||||
type: "get_http_response_body_info_request",
|
||||
responseId,
|
||||
});
|
||||
const info = await bodyInfo();
|
||||
|
||||
notify: (context, payload) => {
|
||||
this.#sendPayload(context, payload, null);
|
||||
},
|
||||
return createResponseBody(
|
||||
{
|
||||
responseId,
|
||||
contentLength: info.contentLength,
|
||||
contentType: info.contentType ?? null,
|
||||
complete: info.complete,
|
||||
},
|
||||
async (offset, length) => {
|
||||
const chunk = await this.#sendForReply<ReadHttpResponseBodyChunkResponse>(
|
||||
context,
|
||||
{ type: "read_http_response_body_chunk_request", responseId, offset, length },
|
||||
);
|
||||
return decodeBase64Chunk(chunk.data);
|
||||
},
|
||||
{ refresh: bodyInfo },
|
||||
);
|
||||
};
|
||||
|
||||
stream: (context, payload, onReply) => {
|
||||
this.#sendAndListenForEvents(context, payload, onReply);
|
||||
},
|
||||
const _windowInfo = async () => {
|
||||
if (context.label == null) {
|
||||
throw new Error("Can't get window context without an active window");
|
||||
}
|
||||
const payload: InternalEventPayload = {
|
||||
type: "window_info_request",
|
||||
label: context.label,
|
||||
};
|
||||
|
||||
form: (context, payload, onChange) => {
|
||||
// Built by hand so the event id is available: intermediate re-renders
|
||||
// reply to the original request rather than starting a new one.
|
||||
const eventToSend = this.#buildEventToSend(context, payload, null);
|
||||
return this.#sendForReply<WindowInfoResponse>(context, payload);
|
||||
};
|
||||
|
||||
return new Promise<PromptFormResponse>((resolve) => {
|
||||
const cb = (event: InternalEvent) => {
|
||||
if (event.replyId !== eventToSend.id) return;
|
||||
if (event.payload.type !== "prompt_form_response") return;
|
||||
return {
|
||||
clipboard: {
|
||||
copyText: async (text) => {
|
||||
await this.#sendForReply(context, {
|
||||
type: "copy_text_request",
|
||||
text,
|
||||
});
|
||||
},
|
||||
},
|
||||
toast: {
|
||||
show: async (args) => {
|
||||
await this.#sendForReply(context, {
|
||||
type: "show_toast_request",
|
||||
// Handle default here because null/undefined both convert to None in Rust translation
|
||||
timeout: args.timeout === undefined ? 5000 : args.timeout,
|
||||
...args,
|
||||
});
|
||||
},
|
||||
},
|
||||
window: {
|
||||
requestId: async () => {
|
||||
return (await _windowInfo()).requestId;
|
||||
},
|
||||
async workspaceId(): Promise<string | null> {
|
||||
return (await _windowInfo()).workspaceId;
|
||||
},
|
||||
async environmentId(): Promise<string | null> {
|
||||
return (await _windowInfo()).environmentId;
|
||||
},
|
||||
openUrl: async ({ onNavigate, onClose, ...args }) => {
|
||||
args.label = args.label || `${Math.random()}`;
|
||||
const payload: InternalEventPayload = { type: "open_window_request", ...args };
|
||||
const onEvent = (event: InternalEventPayload) => {
|
||||
if (event.type === "window_navigate_event") {
|
||||
onNavigate?.(event);
|
||||
} else if (event.type === "window_close_event") {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
this.#sendAndListenForEvents(context, payload, onEvent);
|
||||
return {
|
||||
close: () => {
|
||||
const closePayload: InternalEventPayload = {
|
||||
type: "close_window_request",
|
||||
label: args.label,
|
||||
};
|
||||
this.#sendPayload(context, closePayload, null);
|
||||
},
|
||||
};
|
||||
},
|
||||
openExternalUrl: async (url) => {
|
||||
await this.#sendForReply(context, {
|
||||
type: "open_external_url_request",
|
||||
url,
|
||||
});
|
||||
},
|
||||
},
|
||||
prompt: {
|
||||
text: async (args) => {
|
||||
const reply: PromptTextResponse = await this.#sendForReply(context, {
|
||||
type: "prompt_text_request",
|
||||
...args,
|
||||
});
|
||||
return reply.value;
|
||||
},
|
||||
form: async (args) => {
|
||||
// Resolve dynamic callbacks on initial inputs using default values
|
||||
const defaults = applyFormInputDefaults(args.inputs, {});
|
||||
const callArgs: CallPromptFormDynamicArgs = { values: defaults };
|
||||
const resolvedInputs = await applyDynamicFormInput(
|
||||
this.#newCtx(context),
|
||||
args.inputs,
|
||||
callArgs,
|
||||
);
|
||||
const strippedInputs = stripDynamicCallbacks(resolvedInputs);
|
||||
|
||||
const { done, values } = event.payload as PromptFormResponse;
|
||||
if (done) {
|
||||
this.#appToPluginEvents.unlisten(cb);
|
||||
resolve({ values } as PromptFormResponse);
|
||||
return;
|
||||
// Build the event manually so we can get the event ID for keying
|
||||
const eventToSend = this.#buildEventToSend(
|
||||
context,
|
||||
{ type: "prompt_form_request", ...args, inputs: strippedInputs },
|
||||
null,
|
||||
);
|
||||
|
||||
// Store original inputs (with dynamic callbacks) for later resolution
|
||||
this.#pendingDynamicForms.set(eventToSend.id, args.inputs);
|
||||
|
||||
const reply = await new Promise<PromptFormResponse>((resolve) => {
|
||||
const cb = (event: InternalEvent) => {
|
||||
if (event.replyId !== eventToSend.id) return;
|
||||
|
||||
if (event.payload.type === "prompt_form_response") {
|
||||
const { done, values } = event.payload as PromptFormResponse;
|
||||
if (done) {
|
||||
// Final response — resolve the promise and clean up
|
||||
this.#appToPluginEvents.unlisten(cb);
|
||||
this.#pendingDynamicForms.delete(eventToSend.id);
|
||||
resolve({ values } as PromptFormResponse);
|
||||
} else {
|
||||
// Intermediate value change — resolve dynamic inputs and send back
|
||||
// Skip empty values (fired on initial mount before user interaction)
|
||||
const storedInputs = this.#pendingDynamicForms.get(eventToSend.id);
|
||||
if (storedInputs && values && Object.keys(values).length > 0) {
|
||||
const ctx = this.#newCtx(context);
|
||||
const callArgs: CallPromptFormDynamicArgs = { values };
|
||||
applyDynamicFormInput(ctx, storedInputs, callArgs)
|
||||
.then((resolvedInputs) => {
|
||||
const stripped = stripDynamicCallbacks(resolvedInputs);
|
||||
this.#sendPayload(
|
||||
context,
|
||||
{ type: "prompt_form_request", ...args, inputs: stripped },
|
||||
eventToSend.id,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to resolve dynamic form inputs", err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
this.#appToPluginEvents.listen(cb);
|
||||
|
||||
// Send the initial event after we start listening (to prevent race)
|
||||
this.#sendEvent(eventToSend);
|
||||
});
|
||||
|
||||
return reply.values;
|
||||
},
|
||||
},
|
||||
httpResponse: {
|
||||
find: async (args) => {
|
||||
const payload = {
|
||||
type: "find_http_responses_request",
|
||||
...args,
|
||||
} as const;
|
||||
const { httpResponses } = await this.#sendForReply<FindHttpResponsesResponse>(
|
||||
context,
|
||||
payload,
|
||||
);
|
||||
return httpResponses.map(forPlugin);
|
||||
},
|
||||
body: ({ responseId }) => storedBody(responseId),
|
||||
},
|
||||
grpcRequest: {
|
||||
render: async (args) => {
|
||||
const payload = {
|
||||
type: "render_grpc_request_request",
|
||||
...args,
|
||||
} as const;
|
||||
const { grpcRequest } = await this.#sendForReply<RenderGrpcRequestResponse>(
|
||||
context,
|
||||
payload,
|
||||
);
|
||||
return grpcRequest;
|
||||
},
|
||||
},
|
||||
httpRequest: {
|
||||
getById: async (args) => {
|
||||
const payload = {
|
||||
type: "get_http_request_by_id_request",
|
||||
...args,
|
||||
} as const;
|
||||
const { httpRequest } = await this.#sendForReply<GetHttpRequestByIdResponse>(
|
||||
context,
|
||||
payload,
|
||||
);
|
||||
return httpRequest;
|
||||
},
|
||||
send: async (args) => {
|
||||
const payload = {
|
||||
type: "send_http_request_request",
|
||||
...args,
|
||||
} as const;
|
||||
const { httpResponse, body } = await this.#sendForReply<SendHttpRequestResponse>(
|
||||
context,
|
||||
payload,
|
||||
);
|
||||
|
||||
// A send with no request behind it saves nothing, so the reply
|
||||
// carries the only copy of its body. A saved one is read back from
|
||||
// the host like any other. Callers get the same thing either way.
|
||||
if (body == null) {
|
||||
return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) };
|
||||
}
|
||||
|
||||
onChange(values ?? {})
|
||||
.then((next) => {
|
||||
if (next != null) this.#sendPayload(context, next, eventToSend.id);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error("Failed to resolve dynamic form inputs", err);
|
||||
});
|
||||
};
|
||||
this.#appToPluginEvents.listen(cb);
|
||||
|
||||
// Sent after the listener is attached, to prevent a race.
|
||||
this.#sendEvent(eventToSend);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
#newCtx(context: PluginContext): Context {
|
||||
return createPluginContext(this.#transport, context);
|
||||
const bytes = decodeBase64Chunk(body);
|
||||
return {
|
||||
httpResponse: forPlugin(httpResponse),
|
||||
body: createResponseBody(
|
||||
{
|
||||
responseId: httpResponse.id,
|
||||
contentLength: bytes.byteLength,
|
||||
contentType:
|
||||
httpResponse.headers.find((h) => h.name.toLowerCase() === "content-type")
|
||||
?.value ?? null,
|
||||
// The host waited for the whole send before replying.
|
||||
complete: true,
|
||||
},
|
||||
async (offset, length) => bytes.slice(offset, offset + length),
|
||||
),
|
||||
};
|
||||
},
|
||||
render: async (args) => {
|
||||
const payload = {
|
||||
type: "render_http_request_request",
|
||||
...args,
|
||||
} as const;
|
||||
const { httpRequest } = await this.#sendForReply<RenderHttpRequestResponse>(
|
||||
context,
|
||||
payload,
|
||||
);
|
||||
return httpRequest;
|
||||
},
|
||||
list: async (args?: { folderId?: string }) => {
|
||||
const payload: InternalEventPayload = {
|
||||
type: "list_http_requests_request",
|
||||
folderId: args?.folderId,
|
||||
} satisfies ListHttpRequestsRequest & { type: "list_http_requests_request" };
|
||||
const { httpRequests } = await this.#sendForReply<ListHttpRequestsResponse>(
|
||||
context,
|
||||
payload,
|
||||
);
|
||||
return httpRequests;
|
||||
},
|
||||
create: async (args) => {
|
||||
const payload = {
|
||||
type: "upsert_model_request",
|
||||
model: {
|
||||
name: "",
|
||||
method: "GET",
|
||||
...args,
|
||||
id: "",
|
||||
model: "http_request",
|
||||
},
|
||||
} as InternalEventPayload;
|
||||
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
|
||||
return response.model as HttpRequest;
|
||||
},
|
||||
update: async (args) => {
|
||||
const payload = {
|
||||
type: "upsert_model_request",
|
||||
model: {
|
||||
model: "http_request",
|
||||
...args,
|
||||
},
|
||||
} as InternalEventPayload;
|
||||
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
|
||||
return response.model as HttpRequest;
|
||||
},
|
||||
delete: async (args) => {
|
||||
const payload = {
|
||||
type: "delete_model_request",
|
||||
model: "http_request",
|
||||
id: args.id,
|
||||
} as InternalEventPayload;
|
||||
const response = await this.#sendForReply<DeleteModelResponse>(context, payload);
|
||||
return response.model as HttpRequest;
|
||||
},
|
||||
},
|
||||
folder: {
|
||||
list: async () => {
|
||||
const payload = { type: "list_folders_request" } as const;
|
||||
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
|
||||
return folders;
|
||||
},
|
||||
getById: async (args: { id: string }) => {
|
||||
const payload = { type: "list_folders_request" } as const;
|
||||
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
|
||||
return folders.find((f) => f.id === args.id) ?? null;
|
||||
},
|
||||
create: async ({ name, ...args }) => {
|
||||
const payload = {
|
||||
type: "upsert_model_request",
|
||||
model: {
|
||||
...args,
|
||||
name: name ?? "",
|
||||
id: "",
|
||||
model: "folder",
|
||||
},
|
||||
} as InternalEventPayload;
|
||||
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
|
||||
return response.model as Folder;
|
||||
},
|
||||
update: async (args) => {
|
||||
const payload = {
|
||||
type: "upsert_model_request",
|
||||
model: {
|
||||
model: "folder",
|
||||
...args,
|
||||
},
|
||||
} as InternalEventPayload;
|
||||
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
|
||||
return response.model as Folder;
|
||||
},
|
||||
delete: async (args: { id: string }) => {
|
||||
const payload = {
|
||||
type: "delete_model_request",
|
||||
model: "folder",
|
||||
id: args.id,
|
||||
} as InternalEventPayload;
|
||||
const response = await this.#sendForReply<DeleteModelResponse>(context, payload);
|
||||
return response.model as Folder;
|
||||
},
|
||||
},
|
||||
cookies: {
|
||||
getValue: async (args: GetCookieValueRequest) => {
|
||||
const payload = {
|
||||
type: "get_cookie_value_request",
|
||||
...args,
|
||||
} as const;
|
||||
const { value } = await this.#sendForReply<GetCookieValueResponse>(context, payload);
|
||||
return value;
|
||||
},
|
||||
listNames: async () => {
|
||||
const payload = { type: "list_cookie_names_request" } as const;
|
||||
const { names } = await this.#sendForReply<ListCookieNamesResponse>(context, payload);
|
||||
return names;
|
||||
},
|
||||
},
|
||||
templates: {
|
||||
/**
|
||||
* Invoke Yaak's template engine to render a value. If the value is a nested type
|
||||
* (eg. object), it will be recursively rendered.
|
||||
*/
|
||||
render: async (args: TemplateRenderRequest) => {
|
||||
const payload = { type: "template_render_request", ...args } as const;
|
||||
const result = await this.#sendForReply<TemplateRenderResponse>(context, payload);
|
||||
// oxlint-disable-next-line no-explicit-any -- That's okay
|
||||
return result.data as any;
|
||||
},
|
||||
},
|
||||
store: {
|
||||
get: async <T>(key: string) => {
|
||||
const payload = { type: "get_key_value_request", key } as const;
|
||||
const result = await this.#sendForReply<GetKeyValueResponse>(context, payload);
|
||||
return result.value ? (JSON.parse(result.value) as T) : undefined;
|
||||
},
|
||||
set: async <T>(key: string, value: T) => {
|
||||
const valueStr = JSON.stringify(value);
|
||||
const payload: InternalEventPayload = {
|
||||
type: "set_key_value_request",
|
||||
key,
|
||||
value: valueStr,
|
||||
};
|
||||
await this.#sendForReply<GetKeyValueResponse>(context, payload);
|
||||
},
|
||||
delete: async (key: string) => {
|
||||
const payload = { type: "delete_key_value_request", key } as const;
|
||||
const result = await this.#sendForReply<DeleteKeyValueResponse>(context, payload);
|
||||
return result.deleted;
|
||||
},
|
||||
},
|
||||
plugin: {
|
||||
reload: () => {
|
||||
this.#sendPayload(context, { type: "reload_response", silent: true }, null);
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
list: async () => {
|
||||
const payload = {
|
||||
type: "list_open_workspaces_request",
|
||||
} as InternalEventPayload;
|
||||
const response = await this.#sendForReply<ListOpenWorkspacesResponse>(context, payload);
|
||||
return response.workspaces.map((w) => {
|
||||
// Internal workspace info includes label field not in public API
|
||||
type WorkspaceInfoInternal = typeof w & { label?: string };
|
||||
return {
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
// Hide label from plugin authors, but keep it for internal routing
|
||||
_label: (w as WorkspaceInfoInternal).label as string,
|
||||
};
|
||||
});
|
||||
},
|
||||
withContext: (workspaceHandle: { id: string; name: string; _label?: string }) => {
|
||||
// Create a new context with the workspace's window label
|
||||
const newContext: PluginContext = {
|
||||
...context,
|
||||
label: workspaceHandle._label || null,
|
||||
workspaceId: workspaceHandle.id,
|
||||
};
|
||||
return this.#newCtx(newContext);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function stripDynamicCallbacks(inputs: { dynamic?: unknown }[]): FormInput[] {
|
||||
return inputs.map((input) => {
|
||||
// oxlint-disable-next-line no-explicit-any -- stripping dynamic from union type
|
||||
const { dynamic: _dynamic, ...rest } = input as any;
|
||||
if ("inputs" in rest && Array.isArray(rest.inputs)) {
|
||||
rest.inputs = stripDynamicCallbacks(rest.inputs);
|
||||
}
|
||||
return rest as FormInput;
|
||||
});
|
||||
}
|
||||
|
||||
function genId(len = 5): string {
|
||||
const alphabet = "01234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
|
||||
@@ -4,12 +4,10 @@ import type {
|
||||
DynamicAuthenticationArg,
|
||||
DynamicPromptFormArg,
|
||||
DynamicTemplateFunctionArg,
|
||||
TemplateFunctionPlugin,
|
||||
} from "@yaakapp/api";
|
||||
import type {
|
||||
CallHttpAuthenticationActionArgs,
|
||||
CallTemplateFunctionArgs,
|
||||
FormInput,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
|
||||
type AnyDynamicArg = DynamicTemplateFunctionArg | DynamicAuthenticationArg | DynamicPromptFormArg;
|
||||
@@ -75,33 +73,3 @@ export async function applyDynamicFormInput(
|
||||
}
|
||||
return resolvedArgs;
|
||||
}
|
||||
|
||||
/** What a host receives has to be data all the way down. */
|
||||
export function stripDynamicCallbacks(inputs: { dynamic?: unknown }[]): FormInput[] {
|
||||
return inputs.map((input) => {
|
||||
// oxlint-disable-next-line no-explicit-any -- stripping dynamic from union type
|
||||
const { dynamic: _dynamic, ...rest } = input as any;
|
||||
if ("inputs" in rest && Array.isArray(rest.inputs)) {
|
||||
rest.inputs = stripDynamicCallbacks(rest.inputs);
|
||||
}
|
||||
return rest as FormInput;
|
||||
});
|
||||
}
|
||||
|
||||
/** Select options used to carry `name` where they now carry `label`. */
|
||||
export function migrateTemplateFunctionSelectOptions(
|
||||
f: TemplateFunctionPlugin,
|
||||
): TemplateFunctionPlugin {
|
||||
const migratedArgs = f.args.map((a) => {
|
||||
if (a.type === "select") {
|
||||
type LegacyOption = { label?: string; value: string; name?: string };
|
||||
a.options = a.options.map((o) => {
|
||||
const legacy = o as LegacyOption;
|
||||
return { label: legacy.label ?? legacy.name ?? "", value: legacy.value };
|
||||
});
|
||||
}
|
||||
return a;
|
||||
});
|
||||
|
||||
return { ...f, args: migratedArgs };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { TemplateFunctionPlugin } from "@yaakapp/api";
|
||||
|
||||
export function migrateTemplateFunctionSelectOptions(
|
||||
f: TemplateFunctionPlugin,
|
||||
): TemplateFunctionPlugin {
|
||||
const migratedArgs = f.args.map((a) => {
|
||||
if (a.type === "select") {
|
||||
// Migrate old options that had 'name' instead of 'label'
|
||||
type LegacyOption = { label?: string; value: string; name?: string };
|
||||
a.options = a.options.map((o) => {
|
||||
const legacy = o as LegacyOption;
|
||||
return {
|
||||
label: legacy.label ?? legacy.name ?? "",
|
||||
value: legacy.value,
|
||||
};
|
||||
});
|
||||
}
|
||||
return a;
|
||||
});
|
||||
|
||||
return { ...f, args: migratedArgs };
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
# The Yaak plugin sandbox
|
||||
|
||||
A QuickJS interpreter, a small set of globals, and one function that calls the
|
||||
host. That is the whole runtime. Everything else a plugin does — read a request,
|
||||
send one, store a token, ask the user something — is a message the host chose to
|
||||
answer.
|
||||
|
||||
This document is the contract. It is written to be implementable twice: once
|
||||
here, in wasm, for the browser, and once in Rust with `rquickjs`, for the desktop
|
||||
and the CLI. **If the two hosts disagree about anything below, that is a bug in
|
||||
whichever one drifted, not a platform difference to work around.** The promise
|
||||
to plugin authors is that there is one sandbox and it behaves the same
|
||||
everywhere; a promise like that is only worth making if it is enforceable, which
|
||||
is why the browser runs QuickJS rather than the Worker's own JavaScript engine.
|
||||
|
||||
## The engine
|
||||
|
||||
**quickjs-ng**, and only quickjs-ng.
|
||||
|
||||
There is no real choice: `rquickjs` — the Rust binding the desktop host will use
|
||||
— vendors quickjs-ng as a git submodule and offers no alternative. Picking
|
||||
Bellard's upstream for the browser would mean the two hosts run different
|
||||
engines, which is exactly the thing this design exists to prevent.
|
||||
|
||||
| | Version | Notes |
|
||||
|---|---|---|
|
||||
| Browser (this package) | quickjs-ng **0.12.1** | via `@jitl/quickjs-ng-wasmfile-release-sync` 0.32.0 |
|
||||
| Desktop (planned) | quickjs-ng **0.15.1** | via `rquickjs` 0.12.2 |
|
||||
|
||||
**The version skew is a known gap, and closing it is slice-2 work.** Three minor
|
||||
versions is small — the differences are bug fixes and `Temporal` progress, not
|
||||
semantics anything here depends on — but "identical everywhere" is not a claim
|
||||
that survives being approximate indefinitely. Whoever builds the Rust host
|
||||
should pin both sides to the same tag and add a test that asserts the version
|
||||
string matches.
|
||||
|
||||
### Why the sync build, not ASYNCIFY
|
||||
|
||||
`quickjs-emscripten` ships an ASYNCIFY variant that lets guest code call an async
|
||||
host function *synchronously*. We use the plain sync build instead:
|
||||
|
||||
- ASYNCIFY is about twice the wasm size (1.08 MB vs 529 KB) and, measured,
|
||||
**2.2x slower**.
|
||||
- It can only suspend for one host call at a time. A runtime that runs several
|
||||
plugins would have to hold one wasm instance per in-flight call.
|
||||
- We do not need it. The guest gets real `await` anyway: a host function returns
|
||||
a QuickJS deferred promise, the host resolves it, and the host drains the job
|
||||
queue. `ctx.store.get(...)` is an ordinary `await` inside a plugin.
|
||||
|
||||
The only thing lost is a host call that *looks* synchronous to the guest, and no
|
||||
Yaak plugin wants one — the whole `ctx` API has been async since it existed.
|
||||
|
||||
## What exists inside the sandbox
|
||||
|
||||
QuickJS gives you the language and nothing else. Everything below is either
|
||||
installed by `src/guest/globals.ts` or absent. **Both hosts must install exactly
|
||||
this list.**
|
||||
|
||||
### From the engine
|
||||
|
||||
`Object`, `Array`, `Function`, `String`, `Number`, `Boolean`, `Symbol`, `Math`,
|
||||
`JSON`, `Date`, `RegExp`, `Error` and subclasses, `Map`, `Set`, `WeakMap`,
|
||||
`WeakSet`, `WeakRef`, `Promise`, `Proxy`, `Reflect`, `BigInt`, `ArrayBuffer`,
|
||||
`SharedArrayBuffer`, `DataView`, all `TypedArray`s, `globalThis`,
|
||||
`queueMicrotask`, `performance`.
|
||||
|
||||
Language level is ES2023 plus most of ES2024 — `Object.groupBy`,
|
||||
`Array.prototype.at`, `String.prototype.replaceAll`, async generators, private
|
||||
fields, `??=` all work.
|
||||
|
||||
### Installed by the runtime
|
||||
|
||||
| Global | Notes |
|
||||
|---|---|
|
||||
| `console` | `.log/.info/.warn/.error/.debug/.trace`. Arguments are formatted to a string **inside** the sandbox, so only strings cross out — a cycle or an exotic prototype is the guest's problem, not the host's. |
|
||||
| `setTimeout` / `clearTimeout` | The host holds the real timer; QuickJS has no clock to wake on. A sandbox torn down mid-wait takes its pending timers with it. |
|
||||
| `TextEncoder` / `TextDecoder` | UTF-8 only. Pure JavaScript, in-sandbox — a bridge would cost a copy each way. Lone surrogates encode to U+FFFD, matching the standard. |
|
||||
| `btoa` / `atob` | Latin-1, same narrow contract as the browser's. |
|
||||
|
||||
### Deliberately absent
|
||||
|
||||
`fetch`, `XMLHttpRequest`, `WebSocket`, `crypto`, `structuredClone`, `URL`,
|
||||
`URLSearchParams`, `setInterval`, `require`, `module`, `process`, `Buffer`,
|
||||
`std`, `os`, and every Node built-in.
|
||||
|
||||
- **Network and storage are absent because they are `ctx`'s job.** A plugin that
|
||||
could open its own socket would defeat the point of the sandbox and would not
|
||||
work in a browser anyway.
|
||||
- **`setInterval` is absent** because an interval is a timer that rearms and
|
||||
nothing in a plugin should be polling. Build one from `setTimeout`, visibly.
|
||||
- **`crypto` is absent, and this is the one real gap.** The decided direction is
|
||||
pure-JavaScript `@noble/*` inside the sandbox: audited, dependency-free,
|
||||
identical on both hosts, no host API to keep in sync. A `yaak.crypto` builtin
|
||||
is the escape hatch **if** a hot path is measured, not before. Concretely,
|
||||
`template-function-uuid` does not run in the sandbox today because its `uuid`
|
||||
dependency reaches for `node:crypto`; that is a slice-2 conversion, not a
|
||||
missing capability.
|
||||
- **`URL` is absent** only because nothing has needed it yet. It is a reasonable
|
||||
future addition; it must be added to both hosts together.
|
||||
|
||||
## The module contract
|
||||
|
||||
A module arrives as **source text**, not a file — there is no filesystem, and in
|
||||
a browser there could not be one.
|
||||
|
||||
It is evaluated as CommonJS, via `new Function("module", "exports", "require", source)`,
|
||||
and must assign `module.exports.plugin` (or `module.exports.default`). `new
|
||||
Function` rather than an ES module is deliberate: the bundle's top-level names
|
||||
cannot collide with the shell's, and the source needs no loader hook.
|
||||
|
||||
`require` exists **only to throw**, naming the specifier. A bundle that still
|
||||
calls it was not bundled for this target, and saying which module is missing
|
||||
beats an `undefined` that surfaces ten frames later.
|
||||
|
||||
Bundling requirements: CommonJS, no external modules, no Node built-ins, ES2022.
|
||||
`scripts/bundle-sandbox-plugins.mjs` does this today; what a real
|
||||
`yaakcli build --target sandbox` needs is listed at the bottom of that file.
|
||||
|
||||
## The host interface
|
||||
|
||||
Four functions, installed on `globalThis` before any plugin code runs. A Rust
|
||||
host must expose the same four with the same names and shapes.
|
||||
|
||||
| Function | Direction | Shape |
|
||||
|---|---|---|
|
||||
| `__yaak_call(envelopeJson)` | guest → host | Returns a **promise** of the reply JSON. The one door out. |
|
||||
| `__yaak_log(level, message)` | guest → host | Both strings. Fire and forget. |
|
||||
| `__yaak_timer_start(id, ms)` | guest → host | Host calls `__yaak_guest.fireTimer(id)` when due. |
|
||||
| `__yaak_timer_cancel(id)` | guest → host | |
|
||||
|
||||
And the guest exposes `globalThis.__yaak_guest`:
|
||||
|
||||
| Method | Shape |
|
||||
|---|---|
|
||||
| `load(source, pluginRefId)` | Evaluate a module. Throws if it exports no `plugin`. |
|
||||
| `summary()` | What the module contributes, as plain data. |
|
||||
| `dispatch(envelopeJson)` | Returns a promise of the reply payload JSON. |
|
||||
| `fireTimer(id)` | |
|
||||
|
||||
### Envelopes
|
||||
|
||||
Both directions carry `InternalEventPayload` from
|
||||
`crates/yaak-plugins/src/events.rs`, **unchanged**. That is what makes a plugin
|
||||
unable to tell which runtime it is in.
|
||||
|
||||
```jsonc
|
||||
// dispatch, host → guest
|
||||
{ "context": { "id": "...", "label": null, "workspaceId": "..." },
|
||||
"payload": { "type": "call_template_function_request", "name": "...", "args": { ... } } }
|
||||
|
||||
// __yaak_call, guest → host
|
||||
{ "pluginRefId": "auth-bearer",
|
||||
"context": { ... },
|
||||
"payload": { "type": "get_key_value_request", "key": "token" } }
|
||||
```
|
||||
|
||||
`pluginRefId` rides on outgoing calls because one host handler serves every
|
||||
loaded module, and a plugin's stored state is namespaced by which plugin it is —
|
||||
the same namespacing `build_shared_reply` does in `crates/yaak/src/plugin_events.rs`.
|
||||
|
||||
A throw inside a plugin becomes `{"type":"error_response","error":"..."}`, never
|
||||
a crash and never silence: whatever asked gets a message.
|
||||
|
||||
## The `ctx` API
|
||||
|
||||
Built entirely out of `__yaak_call`. See `src/guest/context.ts` — it is the same
|
||||
surface the Node runtime's `PluginInstance` builds, so it is not repeated here.
|
||||
|
||||
What differs is which calls a **host** answers. The browser host answers a
|
||||
deliberately short list (`packages/platform/src/web/plugins.ts`) and refuses the
|
||||
rest by name. Refusing by name matters: a plugin that needs something it cannot
|
||||
have should fail with a sentence someone can act on.
|
||||
|
||||
Answered in the browser today: `get_key_value`, `set_key_value`,
|
||||
`delete_key_value`, `show_toast`. Everything else — sends, model reads and
|
||||
writes, prompts, response bodies, window info — refuses. Those are capability
|
||||
decisions, not oversights, and each should be added one at a time.
|
||||
|
||||
`ctx.window.openUrl` throws in *every* sandbox host: a plugin-opened window is a
|
||||
desktop affordance with no browser equivalent, and handing back a handle whose
|
||||
`close()` does nothing would be worse.
|
||||
|
||||
## Isolation and limits
|
||||
|
||||
One runtime per worker, **one context per module**. A context is the isolation
|
||||
boundary — its own globals, its own `Object`, its own prototypes — so two plugins
|
||||
cannot see or patch each other. Sharing the runtime is deliberate: the engine and
|
||||
its wasm instance are the expensive part; contexts are not.
|
||||
|
||||
| Limit | Value | Why |
|
||||
|---|---|---|
|
||||
| Memory | 256 MB per runtime | Sized for an importer holding a large document and the objects it parses into. |
|
||||
| Stack | 2 MB | Deep recursion becomes a guest stack overflow, not a worker crash. |
|
||||
| Synchronous execution | 60 s | A watchdog for `while (true)`, **not** a limit on real work. |
|
||||
|
||||
The watchdog bounds *synchronous* execution only. A plugin awaiting the host is
|
||||
not looping, so the clock stops for the duration of a host call and restarts
|
||||
when the guest resumes. It is generous because it costs nothing to be: plugins
|
||||
run in their own worker, so one stuck there blocks no database command and no
|
||||
frame. It is sized off the slowest real work measured — GitHub's 12.3 MB OpenAPI
|
||||
description takes about 2.5 s (`bench/import.mjs`) — with room for a document
|
||||
several times larger before a legitimate import looks like a hang.
|
||||
|
||||
## Where the sandbox runs, and why not in the database worker
|
||||
|
||||
In the browser: a **dedicated worker owned by the tab**, separate from the
|
||||
SharedWorker that owns the database.
|
||||
|
||||
- Plugin work is slow by design, and the database worker answers every tab's
|
||||
commands synchronously. A large import in there would stall every other tab's
|
||||
reads.
|
||||
- A plugin that never returns can be ended with `terminate()`. You cannot do
|
||||
that to the worker holding the database.
|
||||
- The capabilities plugins actually ask for — a prompt, a toast, the active
|
||||
request — belong to a tab, not to a database. Routing through the tab is the
|
||||
shorter path, not a detour.
|
||||
|
||||
The cost is that `ctx.store` goes worker → tab → database worker. It is a message
|
||||
either way, and this is the direction where a stuck plugin costs nothing.
|
||||
|
||||
Template rendering is the one flow that runs backwards: rendering happens in the
|
||||
engine, in the database worker, but the functions it calls live here. So the
|
||||
engine is handed a callback that asks the tab, which asks the sandbox. See
|
||||
`templateBridge` in `packages/platform/src/web/worker.ts`.
|
||||
|
||||
## Plugins versus scripts
|
||||
|
||||
The shell is **not plugin-shaped underneath**. `load` takes source; `dispatch`
|
||||
takes an event. What a module *is* — a plugin today, a workspace script later —
|
||||
is decided by the payloads the host sends, not by the runtime.
|
||||
|
||||
That matters for one reason. A plugin is installed, so someone consented to it,
|
||||
and a plugin may one day escalate to a full Node runtime by asking. **A script
|
||||
arrives inside a workspace — as data, through an import, a git sync, a shared
|
||||
repository — with no consent moment at all.** So scripts get this sandbox and
|
||||
only this sandbox, forever, regardless of feature pressure. Any capability added
|
||||
below must be evaluated against the script case, which is the stricter one:
|
||||
"would I want this to run because someone opened a workspace a stranger sent
|
||||
them?"
|
||||
|
||||
Expected differences when scripts arrive, none of them built yet:
|
||||
|
||||
- A different payload set (`run_script_request` and friends) — same envelope.
|
||||
- A tighter host-call allowlist. A script should probably not reach `ctx.store`
|
||||
at all, and certainly not another plugin's namespace.
|
||||
- A much shorter watchdog. A pre-request script that runs for a minute is broken;
|
||||
an importer that does is working.
|
||||
|
||||
## Performance
|
||||
|
||||
QuickJS is an interpreter with no JIT. Measured on GitHub's 12.3 MB OpenAPI
|
||||
description (1220 requests imported, **identical output** in both engines):
|
||||
|
||||
| | First run | Best of 6 |
|
||||
|---|---|---|
|
||||
| Node (V8) | 304 ms | 164 ms |
|
||||
| QuickJS sandbox | 2503 ms | 2017 ms |
|
||||
|
||||
That is **8x on the first run** and about **12x once V8 has compiled** — well
|
||||
inside the 10–50x folklore, and the first-run number is the one a user waits for
|
||||
because an import happens once. Reproduce with:
|
||||
|
||||
```bash
|
||||
node packages/plugin-sandbox/bench/import.mjs <spec.json> 6
|
||||
```
|
||||
|
||||
**Conclusion: importers stay in the sandbox.** 2.5 s in a worker, behind a
|
||||
progress state, for the largest public API description that exists, is a fine
|
||||
trade for one runtime everywhere. Revisit if a real document is measured
|
||||
materially worse — the escape hatch is a host builtin for the hot path, not a
|
||||
second runtime.
|
||||
|
||||
Boot cost is small: about 80–140 ms to instantiate the wasm and load a plugin,
|
||||
paid once and lazily, so a session that never touches a plugin never pays it.
|
||||
The wasm is 529 KB, next to the 4.3 MB SQLite one.
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* How much slower is an importer inside the sandbox? Yaak's OpenAPI importer is
|
||||
* first-party JavaScript, so a large spec is parsed by whatever engine the
|
||||
* runtime uses. Numbers are in the README.
|
||||
*
|
||||
* node packages/plugin-sandbox/bench/import.mjs <spec.json> [iterations]
|
||||
*/
|
||||
|
||||
import { build } from "esbuild";
|
||||
import { mkdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { bundlePlugin } from "../../../scripts/bundle-sandbox-plugins.mjs";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
const PLUGIN = "importer-openapi";
|
||||
|
||||
const specPath = process.argv[2];
|
||||
const iterations = Number(process.argv[3] ?? 3);
|
||||
if (specPath == null) {
|
||||
console.error("usage: node bench/import.mjs <spec.json> [iterations]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const spec = readFileSync(specPath, "utf8");
|
||||
console.log(`Spec: ${specPath} (${(spec.length / 1024 / 1024).toFixed(1)} MB)`);
|
||||
console.log(`Iterations: ${iterations}\n`);
|
||||
|
||||
async function loadHost() {
|
||||
const outDir = join(root, "node_modules", ".cache", "yaak-plugin-sandbox");
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const outfile = join(outDir, "host.mjs");
|
||||
await build({
|
||||
entryPoints: [join(root, "packages/plugin-sandbox/src/host/sandbox.ts")],
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
outfile,
|
||||
// Resolved from the repo at run time, so the wasm variant is the real one.
|
||||
external: ["@jitl/*", "quickjs-emscripten-core"],
|
||||
});
|
||||
return import(pathToFileURL(outfile).href);
|
||||
}
|
||||
|
||||
const ctxStub = { id: "bench", label: null, workspaceId: "wk_bench" };
|
||||
|
||||
function stats(times) {
|
||||
const sorted = [...times].sort((a, b) => a - b);
|
||||
const mean = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
return { min: sorted[0], median: sorted[Math.floor(sorted.length / 2)], mean };
|
||||
}
|
||||
|
||||
function report(label, times, resourceCount) {
|
||||
const { min, median } = stats(times);
|
||||
console.log(
|
||||
`${label.padEnd(20)} first ${times[0].toFixed(0).padStart(5)} ms ` +
|
||||
`best ${min.toFixed(0).padStart(5)} ms ` +
|
||||
`median ${median.toFixed(0).padStart(5)} ms (${resourceCount} requests)`,
|
||||
);
|
||||
// The spread is the point: V8 compiles this across the first few passes and
|
||||
// QuickJS does not compile at all.
|
||||
console.log(`${" ".repeat(20)} runs: ${times.map((t) => t.toFixed(0)).join(", ")} ms`);
|
||||
return { first: times[0], best: min };
|
||||
}
|
||||
|
||||
/* --------------------------------- Node ---------------------------------- */
|
||||
|
||||
const nodeTimes = [];
|
||||
let nodeCount = 0;
|
||||
{
|
||||
const { createRequire } = await import("node:module");
|
||||
const require = createRequire(join(root, "package.json"));
|
||||
const mod = require(join(root, "plugins", PLUGIN, "build", "index.js"));
|
||||
const plugin = mod.plugin ?? mod.default;
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const started = performance.now();
|
||||
const result = await plugin.importer.onImport(ctxStub, { text: spec });
|
||||
nodeTimes.push(performance.now() - started);
|
||||
nodeCount = result?.resources?.httpRequests?.length ?? 0;
|
||||
}
|
||||
}
|
||||
const node = report("Node (V8)", nodeTimes, nodeCount);
|
||||
|
||||
/* -------------------------------- QuickJS -------------------------------- */
|
||||
|
||||
const quickTimes = [];
|
||||
let quickCount = 0;
|
||||
{
|
||||
const { PluginSandboxHost } = await loadHost();
|
||||
const source = await bundlePlugin(PLUGIN);
|
||||
|
||||
const host = new PluginSandboxHost(
|
||||
async () => JSON.stringify({ type: "empty_response" }),
|
||||
(log) => console.error(`[${log.level}] ${log.message}`),
|
||||
);
|
||||
|
||||
const loadStarted = performance.now();
|
||||
await host.load(PLUGIN, source);
|
||||
console.log(`(sandbox boot + load: ${(performance.now() - loadStarted).toFixed(0)} ms)\n`);
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const started = performance.now();
|
||||
const reply = JSON.parse(
|
||||
await host.dispatch(
|
||||
PLUGIN,
|
||||
JSON.stringify({ context: ctxStub, payload: { type: "import_request", content: spec } }),
|
||||
),
|
||||
);
|
||||
quickTimes.push(performance.now() - started);
|
||||
if (reply.type === "error_response") throw new Error(reply.error);
|
||||
quickCount = reply.resources?.httpRequests?.length ?? 0;
|
||||
}
|
||||
host.dispose();
|
||||
}
|
||||
const quick = report("QuickJS (sandbox)", quickTimes, quickCount);
|
||||
|
||||
console.log(
|
||||
`\nFirst run (what a user waits for): ${(quick.first / 1000).toFixed(1)}s in the sandbox ` +
|
||||
`vs ${(node.first / 1000).toFixed(1)}s in Node — ${(quick.first / node.first).toFixed(1)}x.`,
|
||||
);
|
||||
console.log(
|
||||
`Best run (both warm): ${(quick.best / node.best).toFixed(1)}x, which is the ceiling once V8 has compiled.`,
|
||||
);
|
||||
if (nodeCount !== quickCount) {
|
||||
console.log(`WARNING: request counts differ (${nodeCount} vs ${quickCount}) — not the same work.`);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* The shell has to reach QuickJS as source text. Emitted as a `.ts` module, not
|
||||
* a `.js` asset, so Vite and plain Node get at it the same way. Committed, like
|
||||
* the wasm packages, so a checkout builds without this having run.
|
||||
*/
|
||||
|
||||
import { build } from "esbuild";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const outDir = join(here, "src", "generated");
|
||||
|
||||
const result = await build({
|
||||
entryPoints: [join(here, "src", "guest", "index.ts")],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
target: "es2022",
|
||||
minify: false,
|
||||
legalComments: "none",
|
||||
});
|
||||
|
||||
const source = result.outputFiles[0].text;
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(outDir, "guest.ts"),
|
||||
[
|
||||
"// Generated by build-guest.mjs. Do not edit.",
|
||||
"//",
|
||||
"// The runtime shell, as source text, for evaluation inside QuickJS.",
|
||||
"// Regenerate with `npm run build --workspace @yaakapp-internal/plugin-sandbox`.",
|
||||
"",
|
||||
`export const GUEST_SOURCE = ${JSON.stringify(source)};`,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
console.log(`Bundled guest shell: ${(source.length / 1024).toFixed(1)} KB`);
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/plugin-sandbox",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"bootstrap": "npm run build",
|
||||
"build": "node build-guest.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ng-wasmfile-release-sync": "^0.32.0",
|
||||
"quickjs-emscripten-core": "^0.32.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.28.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,238 +0,0 @@
|
||||
/**
|
||||
* Everything a plugin can reach that isn't the language itself. The Rust host
|
||||
* must install this same list; see the README.
|
||||
*/
|
||||
|
||||
declare const __yaak_log: (level: string, message: string) => void;
|
||||
declare const __yaak_timer_start: (id: number, ms: number) => void;
|
||||
declare const __yaak_timer_cancel: (id: number) => void;
|
||||
|
||||
/* -------------------------------- console -------------------------------- */
|
||||
|
||||
/** Formatted in here, so only strings cross the boundary. */
|
||||
function formatArgs(args: unknown[]): string {
|
||||
return args
|
||||
.map((arg) => {
|
||||
if (typeof arg === "string") return arg;
|
||||
if (arg instanceof Error) return arg.stack ?? `${arg.name}: ${arg.message}`;
|
||||
try {
|
||||
return JSON.stringify(arg, replacer()) ?? String(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function replacer(): (key: string, value: unknown) => unknown {
|
||||
const seen = new WeakSet<object>();
|
||||
return (_key, value) => {
|
||||
if (typeof value === "bigint") return `${value}n`;
|
||||
if (typeof value === "function") return `[Function ${value.name || "anonymous"}]`;
|
||||
if (typeof value === "object" && value !== null) {
|
||||
if (seen.has(value)) return "[Circular]";
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
||||
function installConsole(): void {
|
||||
const log = (level: string) => (...args: unknown[]) => __yaak_log(level, formatArgs(args));
|
||||
(globalThis as Record<string, unknown>).console = {
|
||||
log: log("log"),
|
||||
info: log("info"),
|
||||
warn: log("warn"),
|
||||
error: log("error"),
|
||||
debug: log("debug"),
|
||||
trace: log("debug"),
|
||||
};
|
||||
}
|
||||
|
||||
/* --------------------------------- timers -------------------------------- */
|
||||
|
||||
/** QuickJS has no clock to wake on, so the host holds the real timer. */
|
||||
const timerCallbacks = new Map<number, () => void>();
|
||||
let nextTimerId = 1;
|
||||
|
||||
function installTimers(): void {
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
|
||||
g.setTimeout = (callback: (...a: unknown[]) => void, ms?: number, ...args: unknown[]) => {
|
||||
const id = nextTimerId++;
|
||||
timerCallbacks.set(id, () => callback(...args));
|
||||
__yaak_timer_start(id, Math.max(0, Number(ms) || 0));
|
||||
return id;
|
||||
};
|
||||
|
||||
g.clearTimeout = (id: number) => {
|
||||
if (!timerCallbacks.delete(id)) return;
|
||||
__yaak_timer_cancel(id);
|
||||
};
|
||||
|
||||
// An interval is a timer that rearms, and nothing in a plugin should poll.
|
||||
g.setInterval = undefined;
|
||||
g.clearInterval = undefined;
|
||||
}
|
||||
|
||||
/** Called by the host when a timer comes due. */
|
||||
function fireTimer(id: number): void {
|
||||
const callback = timerCallbacks.get(id);
|
||||
timerCallbacks.delete(id);
|
||||
callback?.();
|
||||
}
|
||||
|
||||
/* ------------------------------- text codecs ------------------------------ */
|
||||
|
||||
class SandboxTextEncoder {
|
||||
readonly encoding = "utf-8";
|
||||
|
||||
encode(input = ""): Uint8Array {
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
let code = input.charCodeAt(i);
|
||||
// A lone surrogate becomes U+FFFD, as the standard encoder does.
|
||||
if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const next = input.charCodeAt(i + 1);
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
code = (code - 0xd800) * 0x400 + (next - 0xdc00) + 0x10000;
|
||||
i++;
|
||||
} else {
|
||||
code = 0xfffd;
|
||||
}
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
code = 0xfffd;
|
||||
}
|
||||
|
||||
if (code < 0x80) out.push(code);
|
||||
else if (code < 0x800) out.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
|
||||
else if (code < 0x10000)
|
||||
out.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
|
||||
else
|
||||
out.push(
|
||||
0xf0 | (code >> 18),
|
||||
0x80 | ((code >> 12) & 0x3f),
|
||||
0x80 | ((code >> 6) & 0x3f),
|
||||
0x80 | (code & 0x3f),
|
||||
);
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
}
|
||||
|
||||
class SandboxTextDecoder {
|
||||
readonly encoding = "utf-8";
|
||||
|
||||
decode(input?: ArrayBuffer | ArrayBufferView): string {
|
||||
if (input == null) return "";
|
||||
const bytes =
|
||||
input instanceof Uint8Array
|
||||
? input
|
||||
: ArrayBuffer.isView(input)
|
||||
? new Uint8Array(input.buffer, input.byteOffset, input.byteLength)
|
||||
: new Uint8Array(input);
|
||||
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; ) {
|
||||
const byte = bytes[i]!;
|
||||
let code: number;
|
||||
let size: number;
|
||||
if (byte < 0x80) {
|
||||
code = byte;
|
||||
size = 1;
|
||||
} else if ((byte & 0xe0) === 0xc0) {
|
||||
code = byte & 0x1f;
|
||||
size = 2;
|
||||
} else if ((byte & 0xf0) === 0xe0) {
|
||||
code = byte & 0x0f;
|
||||
size = 3;
|
||||
} else if ((byte & 0xf8) === 0xf0) {
|
||||
code = byte & 0x07;
|
||||
size = 4;
|
||||
} else {
|
||||
out += "�";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i + size > bytes.length) {
|
||||
out += "�";
|
||||
break;
|
||||
}
|
||||
for (let k = 1; k < size; k++) {
|
||||
const cont = bytes[i + k]!;
|
||||
if ((cont & 0xc0) !== 0x80) {
|
||||
code = -1;
|
||||
break;
|
||||
}
|
||||
code = (code << 6) | (cont & 0x3f);
|
||||
}
|
||||
i += size;
|
||||
|
||||
if (code < 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) out += "�";
|
||||
else if (code < 0x10000) out += String.fromCharCode(code);
|
||||
else {
|
||||
const c = code - 0x10000;
|
||||
out += String.fromCharCode(0xd800 + (c >> 10), 0xdc00 + (c & 0x3ff));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
function installTextCodecs(): void {
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
g.TextEncoder = SandboxTextEncoder;
|
||||
g.TextDecoder = SandboxTextDecoder;
|
||||
}
|
||||
|
||||
/* ------------------------------ base64 helpers ---------------------------- */
|
||||
|
||||
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
function installBase64(): void {
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
|
||||
g.btoa = (input: string): string => {
|
||||
let out = "";
|
||||
for (let i = 0; i < input.length; i += 3) {
|
||||
const a = input.charCodeAt(i);
|
||||
const b = input.charCodeAt(i + 1);
|
||||
const c = input.charCodeAt(i + 2);
|
||||
if (a > 0xff || b > 0xff || c > 0xff) {
|
||||
throw new Error("btoa: string contains characters outside of the Latin1 range");
|
||||
}
|
||||
const chunk = (a << 16) | ((Number.isNaN(b) ? 0 : b) << 8) | (Number.isNaN(c) ? 0 : c);
|
||||
out += B64[(chunk >> 18) & 63]! + B64[(chunk >> 12) & 63]!;
|
||||
out += Number.isNaN(b) ? "=" : B64[(chunk >> 6) & 63]!;
|
||||
out += Number.isNaN(c) ? "=" : B64[chunk & 63]!;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
g.atob = (input: string): string => {
|
||||
const clean = input.replace(/[\t\n\f\r ]/g, "").replace(/=+$/, "");
|
||||
let out = "";
|
||||
let bits = 0;
|
||||
let acc = 0;
|
||||
for (const ch of clean) {
|
||||
const value = B64.indexOf(ch);
|
||||
if (value < 0) throw new Error("atob: string contains invalid characters");
|
||||
acc = (acc << 6) | value;
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out += String.fromCharCode((acc >> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
export function installGlobals(): { fireTimer: (id: number) => void } {
|
||||
installConsole();
|
||||
installTimers();
|
||||
installTextCodecs();
|
||||
installBase64();
|
||||
return { fireTimer };
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
/**
|
||||
* What QuickJS evaluates before any untrusted code does: install globals, load
|
||||
* one module, answer events against it.
|
||||
*
|
||||
* `load` takes source and `dispatch` takes an event, so what a module *is* — a
|
||||
* plugin today, a workspace script later — is the host's decision, not this
|
||||
* file's. See the README on why scripts never get a second runtime.
|
||||
*/
|
||||
|
||||
import type { PluginDefinition } from "@yaakapp/api";
|
||||
import {
|
||||
applyFormInputDefaults,
|
||||
validateTemplateFunctionArgs,
|
||||
} from "@yaakapp-internal/lib/templateFunction";
|
||||
import {
|
||||
applyDynamicFormInput,
|
||||
migrateTemplateFunctionSelectOptions,
|
||||
stripDynamicCallbacks,
|
||||
} from "@yaakapp-internal/lib/pluginForms";
|
||||
import type {
|
||||
GrpcRequestAction,
|
||||
HttpAuthenticationAction,
|
||||
HttpRequestAction,
|
||||
ImportResources,
|
||||
InternalEventPayload,
|
||||
PluginContext,
|
||||
TemplateFunction,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
import {
|
||||
createPluginContext,
|
||||
type PluginTransport,
|
||||
} from "@yaakapp-internal/lib/pluginContext";
|
||||
import { installGlobals } from "./globals";
|
||||
|
||||
declare const __yaak_call: (payloadJson: string) => Promise<string>;
|
||||
|
||||
const { fireTimer } = installGlobals();
|
||||
|
||||
let mod: PluginDefinition = {};
|
||||
let pluginRefId = "";
|
||||
|
||||
/**
|
||||
* `require` exists only to fail, by name: a bundle that still calls it was not
|
||||
* built for this target, and naming the specifier beats an undefined that
|
||||
* surfaces ten frames later.
|
||||
*/
|
||||
function load(source: string, refId: string): void {
|
||||
const module: { exports: Record<string, unknown> } = { exports: {} };
|
||||
const require = (specifier: string) => {
|
||||
throw new Error(
|
||||
`Module "${specifier}" is not available in the sandbox runtime. ` +
|
||||
`Plugins must be bundled with no external or built-in modules.`,
|
||||
);
|
||||
};
|
||||
|
||||
// Isolation is the QuickJS context around this, not a lint rule.
|
||||
// oxlint-disable-next-line no-implied-eval
|
||||
const factory = new Function("module", "exports", "require", source);
|
||||
factory(module, module.exports, require);
|
||||
|
||||
const loaded = (module.exports.plugin ?? module.exports.default) as PluginDefinition | undefined;
|
||||
if (loaded == null || typeof loaded !== "object") {
|
||||
throw new Error("Module did not export `plugin`");
|
||||
}
|
||||
mod = loaded;
|
||||
pluginRefId = refId;
|
||||
}
|
||||
|
||||
function summary(): Record<string, unknown> {
|
||||
return {
|
||||
templateFunctions: (mod.templateFunctions ?? []).map((f) => f.name),
|
||||
authentication: mod.authentication?.name ?? null,
|
||||
importer: mod.importer != null,
|
||||
filter: mod.filter != null,
|
||||
themes: (mod.themes ?? []).length,
|
||||
httpRequestActions: (mod.httpRequestActions ?? []).length,
|
||||
workspaceActions: (mod.workspaceActions ?? []).length,
|
||||
folderActions: (mod.folderActions ?? []).length,
|
||||
grpcRequestActions: (mod.grpcRequestActions ?? []).length,
|
||||
websocketRequestActions: (mod.websocketRequestActions ?? []).length,
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY: InternalEventPayload = { type: "empty_response" };
|
||||
|
||||
/**
|
||||
* Every branch mirrors the Node runtime's: same payloads, so a plugin cannot
|
||||
* tell which runtime it is in. An unmatched event gets `empty_response` rather
|
||||
* than silence, so no caller waits forever.
|
||||
*/
|
||||
/**
|
||||
* No `stream` and no `form`: both need the host to hold a conversation open,
|
||||
* which this protocol deliberately does not. `openUrl` refuses and a prompt
|
||||
* form is drawn once from its defaults, rather than quietly doing nothing.
|
||||
*/
|
||||
const transport: PluginTransport = {
|
||||
async request(context, payload) {
|
||||
// The id rides along because one host handler serves every loaded module,
|
||||
// and a plugin's storage is namespaced by which plugin it is.
|
||||
const replyJson = await __yaak_call(JSON.stringify({ pluginRefId, context, payload }));
|
||||
const reply = JSON.parse(replyJson) as InternalEventPayload & { error?: string };
|
||||
if (reply.type === "error_response") {
|
||||
throw new Error(reply.error || `Host failed to handle ${payload.type}`);
|
||||
}
|
||||
const { type: _type, ...rest } = reply;
|
||||
return rest as Record<string, unknown>;
|
||||
},
|
||||
notify(context, payload) {
|
||||
void __yaak_call(JSON.stringify({ pluginRefId, context, payload }));
|
||||
},
|
||||
};
|
||||
|
||||
async function dispatch(
|
||||
context: PluginContext,
|
||||
payload: InternalEventPayload,
|
||||
): Promise<InternalEventPayload> {
|
||||
const ctx = createPluginContext(transport, context);
|
||||
|
||||
if (payload.type === "boot_request") {
|
||||
await mod.init?.(ctx);
|
||||
return { type: "boot_response" };
|
||||
}
|
||||
|
||||
if (payload.type === "terminate_request") {
|
||||
await mod.dispose?.();
|
||||
return { type: "terminate_response" };
|
||||
}
|
||||
|
||||
if (payload.type === "import_request" && typeof mod.importer?.onImport === "function") {
|
||||
const reply = await mod.importer.onImport(ctx, { text: payload.content });
|
||||
if (reply != null) {
|
||||
return { type: "import_response", resources: reply.resources as ImportResources };
|
||||
}
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
if (payload.type === "filter_request" && typeof mod.filter?.onFilter === "function") {
|
||||
const reply = await mod.filter.onFilter(ctx, {
|
||||
filter: payload.filter,
|
||||
payload: payload.content,
|
||||
mimeType: payload.type,
|
||||
});
|
||||
return { type: "filter_response", ...reply };
|
||||
}
|
||||
|
||||
if (payload.type === "get_themes_request" && Array.isArray(mod.themes)) {
|
||||
return { type: "get_themes_response", themes: mod.themes };
|
||||
}
|
||||
|
||||
/* --------------------------- template functions -------------------------- */
|
||||
|
||||
if (
|
||||
payload.type === "get_template_function_summary_request" &&
|
||||
Array.isArray(mod.templateFunctions)
|
||||
) {
|
||||
const functions: TemplateFunction[] = mod.templateFunctions.map((f) => ({
|
||||
...migrateTemplateFunctionSelectOptions(f),
|
||||
onRender: undefined,
|
||||
}));
|
||||
return { type: "get_template_function_summary_response", pluginRefId, functions };
|
||||
}
|
||||
|
||||
if (
|
||||
payload.type === "get_template_function_config_request" &&
|
||||
Array.isArray(mod.templateFunctions)
|
||||
) {
|
||||
const found = mod.templateFunctions.find((f) => f.name === payload.name);
|
||||
if (found == null) return EMPTY;
|
||||
|
||||
const fn = { ...migrateTemplateFunctionSelectOptions(found), onRender: undefined };
|
||||
payload.values = applyFormInputDefaults(fn.args, payload.values);
|
||||
const resolved = await applyDynamicFormInput(ctx, fn.args, {
|
||||
...payload,
|
||||
purpose: "preview",
|
||||
} as const);
|
||||
|
||||
return {
|
||||
type: "get_template_function_config_response",
|
||||
pluginRefId,
|
||||
function: { ...fn, args: stripDynamicCallbacks(resolved) },
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.type === "call_template_function_request" && Array.isArray(mod.templateFunctions)) {
|
||||
const fn = mod.templateFunctions.find((f) => f.name === payload.name);
|
||||
|
||||
if (
|
||||
payload.args.purpose === "preview" &&
|
||||
(fn?.previewType === "click" || fn?.previewType === "none")
|
||||
) {
|
||||
return {
|
||||
type: "call_template_function_response",
|
||||
value: null,
|
||||
error: "Live preview disabled for this function",
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof fn?.onRender === "function") {
|
||||
const resolved = await applyDynamicFormInput(ctx, fn.args, payload.args);
|
||||
const values = applyFormInputDefaults(resolved, payload.args.values);
|
||||
const error = validateTemplateFunctionArgs(fn.name, resolved, values);
|
||||
if (error && payload.args.purpose !== "preview") {
|
||||
return { type: "call_template_function_response", value: null, error };
|
||||
}
|
||||
|
||||
const result = await fn.onRender(ctx, { ...payload.args, values });
|
||||
return { type: "call_template_function_response", value: result ?? null };
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------- http authentication ------------------------- */
|
||||
|
||||
if (payload.type === "get_http_authentication_summary_request" && mod.authentication) {
|
||||
return { type: "get_http_authentication_summary_response", ...mod.authentication };
|
||||
}
|
||||
|
||||
if (payload.type === "get_http_authentication_config_request" && mod.authentication) {
|
||||
const { args, actions } = mod.authentication;
|
||||
payload.values = applyFormInputDefaults(args, payload.values);
|
||||
const resolved = await applyDynamicFormInput(ctx, args, payload);
|
||||
const resolvedActions: HttpAuthenticationAction[] = [];
|
||||
// oxlint-disable-next-line unbound-method
|
||||
for (const { onSelect: _onSelect, ...action } of actions ?? []) resolvedActions.push(action);
|
||||
|
||||
return {
|
||||
type: "get_http_authentication_config_response",
|
||||
args: stripDynamicCallbacks(resolved),
|
||||
actions: resolvedActions,
|
||||
pluginRefId,
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.type === "call_http_authentication_request" && mod.authentication) {
|
||||
const auth = mod.authentication;
|
||||
if (typeof auth.onApply === "function") {
|
||||
const resolved = await applyDynamicFormInput(ctx, auth.args, payload);
|
||||
payload.values = applyFormInputDefaults(resolved, payload.values);
|
||||
return { type: "call_http_authentication_response", ...(await auth.onApply(ctx, payload)) };
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.type === "call_http_authentication_action_request" && mod.authentication != null) {
|
||||
const action = mod.authentication.actions?.[payload.index];
|
||||
if (typeof action?.onSelect === "function") {
|
||||
await action.onSelect(ctx, payload.args);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------- actions ------------------------------- */
|
||||
|
||||
if (payload.type === "get_http_request_actions_request" && Array.isArray(mod.httpRequestActions)) {
|
||||
const actions: HttpRequestAction[] = mod.httpRequestActions.map((a) => ({
|
||||
...a,
|
||||
onSelect: undefined,
|
||||
}));
|
||||
return { type: "get_http_request_actions_response", pluginRefId, actions };
|
||||
}
|
||||
|
||||
if (
|
||||
payload.type === "get_websocket_request_actions_request" &&
|
||||
Array.isArray(mod.websocketRequestActions)
|
||||
) {
|
||||
const actions = mod.websocketRequestActions.map((a) => ({ ...a, onSelect: undefined }));
|
||||
return { type: "get_websocket_request_actions_response", pluginRefId, actions };
|
||||
}
|
||||
|
||||
if (payload.type === "get_grpc_request_actions_request" && Array.isArray(mod.grpcRequestActions)) {
|
||||
const actions: GrpcRequestAction[] = mod.grpcRequestActions.map((a) => ({
|
||||
...a,
|
||||
onSelect: undefined,
|
||||
}));
|
||||
return { type: "get_grpc_request_actions_response", pluginRefId, actions };
|
||||
}
|
||||
|
||||
if (payload.type === "get_workspace_actions_request" && Array.isArray(mod.workspaceActions)) {
|
||||
const actions = mod.workspaceActions.map((a) => ({ ...a, onSelect: undefined }));
|
||||
return { type: "get_workspace_actions_response", pluginRefId, actions };
|
||||
}
|
||||
|
||||
if (payload.type === "get_folder_actions_request" && Array.isArray(mod.folderActions)) {
|
||||
const actions = mod.folderActions.map((a) => ({ ...a, onSelect: undefined }));
|
||||
return { type: "get_folder_actions_response", pluginRefId, actions };
|
||||
}
|
||||
|
||||
const called = await callAction(ctx, payload);
|
||||
if (called) return EMPTY;
|
||||
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
async function callAction(
|
||||
ctx: ReturnType<typeof createPluginContext>,
|
||||
payload: InternalEventPayload,
|
||||
): Promise<boolean> {
|
||||
const lists = {
|
||||
call_http_request_action_request: mod.httpRequestActions,
|
||||
call_websocket_request_action_request: mod.websocketRequestActions,
|
||||
call_grpc_request_action_request: mod.grpcRequestActions,
|
||||
call_workspace_action_request: mod.workspaceActions,
|
||||
call_folder_action_request: mod.folderActions,
|
||||
} as const;
|
||||
|
||||
const list = lists[payload.type as keyof typeof lists];
|
||||
if (!Array.isArray(list)) return false;
|
||||
|
||||
const action = list[(payload as { index: number }).index];
|
||||
if (typeof action?.onSelect !== "function") return false;
|
||||
|
||||
await action.onSelect(ctx, (payload as { args: never }).args);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
(globalThis as Record<string, unknown>).__yaak_guest = {
|
||||
load,
|
||||
summary,
|
||||
fireTimer,
|
||||
dispatch: async (envelopeJson: string): Promise<string> => {
|
||||
const { context, payload } = JSON.parse(envelopeJson) as {
|
||||
context: PluginContext;
|
||||
payload: InternalEventPayload;
|
||||
};
|
||||
try {
|
||||
return JSON.stringify(await dispatch(context, payload));
|
||||
} catch (err) {
|
||||
// A throw from a plugin is an answer, not a crash.
|
||||
const error = (err instanceof Error ? err.message : String(err)).replace(/^Error:\s*/g, "");
|
||||
return JSON.stringify({ type: "error_response", error });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,312 +0,0 @@
|
||||
/**
|
||||
* One runtime, one context per module. The engine choice and the limits below
|
||||
* are argued in this package's README, which is also the spec for the Rust host.
|
||||
*/
|
||||
|
||||
import variant from "@jitl/quickjs-ng-wasmfile-release-sync";
|
||||
import {
|
||||
newQuickJSWASMModuleFromVariant,
|
||||
type QuickJSContext,
|
||||
type QuickJSRuntime,
|
||||
type QuickJSWASMModule,
|
||||
} from "quickjs-emscripten-core";
|
||||
import { GUEST_SOURCE } from "../generated/guest";
|
||||
|
||||
const MEMORY_LIMIT_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
const STACK_SIZE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/** Bounds synchronous execution only: a plugin awaiting the host is not looping. */
|
||||
const SYNC_BUDGET_MS = 60_000;
|
||||
|
||||
export type HostRequestHandler = (envelopeJson: string) => Promise<string>;
|
||||
|
||||
export interface SandboxLog {
|
||||
pluginRefId: string;
|
||||
level: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
let modulePromise: Promise<QuickJSWASMModule> | null = null;
|
||||
|
||||
function quickjs(): Promise<QuickJSWASMModule> {
|
||||
modulePromise ??= newQuickJSWASMModuleFromVariant(variant);
|
||||
return modulePromise;
|
||||
}
|
||||
|
||||
class LoadedPlugin {
|
||||
readonly pluginRefId: string;
|
||||
readonly context: QuickJSContext;
|
||||
/** Set while a dispatch is running; the interrupt handler reads it. */
|
||||
deadline: number | null = null;
|
||||
private nextTimer = new Map<number, ReturnType<typeof setTimeout>>();
|
||||
private disposed = false;
|
||||
|
||||
constructor(pluginRefId: string, context: QuickJSContext) {
|
||||
this.pluginRefId = pluginRefId;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
touch(): void {
|
||||
if (this.deadline != null) this.deadline = Date.now() + SYNC_BUDGET_MS;
|
||||
}
|
||||
|
||||
startTimer(id: number, ms: number, fire: () => void): void {
|
||||
this.nextTimer.set(
|
||||
id,
|
||||
setTimeout(() => {
|
||||
this.nextTimer.delete(id);
|
||||
if (!this.disposed) fire();
|
||||
}, ms),
|
||||
);
|
||||
}
|
||||
|
||||
cancelTimer(id: number): void {
|
||||
const handle = this.nextTimer.get(id);
|
||||
if (handle == null) return;
|
||||
clearTimeout(handle);
|
||||
this.nextTimer.delete(id);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
for (const handle of this.nextTimer.values()) clearTimeout(handle);
|
||||
this.nextTimer.clear();
|
||||
this.context.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginSandboxHost {
|
||||
private runtime: QuickJSRuntime | null = null;
|
||||
private readonly plugins = new Map<string, LoadedPlugin>();
|
||||
|
||||
constructor(
|
||||
private readonly onHostRequest: HostRequestHandler,
|
||||
private readonly onLog: (log: SandboxLog) => void,
|
||||
) {}
|
||||
|
||||
async load(pluginRefId: string, source: string): Promise<Record<string, unknown>> {
|
||||
const module = await quickjs();
|
||||
|
||||
if (this.runtime == null) {
|
||||
this.runtime = module.newRuntime();
|
||||
this.runtime.setMemoryLimit(MEMORY_LIMIT_BYTES);
|
||||
this.runtime.setMaxStackSize(STACK_SIZE_BYTES);
|
||||
this.runtime.setInterruptHandler(() => {
|
||||
const now = Date.now();
|
||||
for (const plugin of this.plugins.values()) {
|
||||
if (plugin.deadline != null && now > plugin.deadline) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
this.plugins.get(pluginRefId)?.dispose();
|
||||
|
||||
const plugin = new LoadedPlugin(pluginRefId, this.runtime.newContext());
|
||||
this.plugins.set(pluginRefId, plugin);
|
||||
|
||||
try {
|
||||
this.installHostFunctions(plugin);
|
||||
this.evalOrThrow(plugin, GUEST_SOURCE, "yaak:sandbox-shell");
|
||||
await this.callGuest(plugin, "load", [source, pluginRefId]);
|
||||
return await this.callGuest(plugin, "summary", []);
|
||||
} catch (err) {
|
||||
plugin.dispose();
|
||||
this.plugins.delete(pluginRefId);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
loaded(): string[] {
|
||||
return Array.from(this.plugins.keys());
|
||||
}
|
||||
|
||||
unload(pluginRefId: string): void {
|
||||
this.plugins.get(pluginRefId)?.dispose();
|
||||
this.plugins.delete(pluginRefId);
|
||||
}
|
||||
|
||||
async dispatch(pluginRefId: string, envelopeJson: string): Promise<string> {
|
||||
const plugin = this.plugins.get(pluginRefId);
|
||||
if (plugin == null) throw new Error(`No plugin loaded as \`${pluginRefId}\``);
|
||||
const reply = await this.callGuest(plugin, "dispatch", [envelopeJson]);
|
||||
return reply as unknown as string;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const plugin of this.plugins.values()) plugin.dispose();
|
||||
this.plugins.clear();
|
||||
this.runtime?.dispose();
|
||||
this.runtime = null;
|
||||
}
|
||||
|
||||
/* ------------------------------ internals ------------------------------- */
|
||||
|
||||
private installHostFunctions(plugin: LoadedPlugin): void {
|
||||
const { context } = plugin;
|
||||
|
||||
const define = (name: string, fn: Parameters<QuickJSContext["newFunction"]>[1]) => {
|
||||
const handle = context.newFunction(name, fn);
|
||||
context.setProp(context.global, name, handle);
|
||||
handle.dispose();
|
||||
};
|
||||
|
||||
define("__yaak_log", (levelHandle, messageHandle) => {
|
||||
this.onLog({
|
||||
pluginRefId: plugin.pluginRefId,
|
||||
level: context.getString(levelHandle),
|
||||
message: context.getString(messageHandle),
|
||||
});
|
||||
});
|
||||
|
||||
define("__yaak_timer_start", (idHandle, msHandle) => {
|
||||
const id = context.getNumber(idHandle);
|
||||
plugin.startTimer(id, context.getNumber(msHandle), () => {
|
||||
plugin.touch();
|
||||
this.callGuestSync(plugin, "fireTimer", [id]);
|
||||
this.pump(plugin);
|
||||
});
|
||||
});
|
||||
|
||||
define("__yaak_timer_cancel", (idHandle) => {
|
||||
plugin.cancelTimer(context.getNumber(idHandle));
|
||||
});
|
||||
|
||||
define("__yaak_call", (envelopeHandle) => {
|
||||
const envelope = context.getString(envelopeHandle);
|
||||
|
||||
const wasWatching = plugin.deadline != null;
|
||||
plugin.deadline = null;
|
||||
|
||||
const settle = this.onHostRequest(envelope).then(
|
||||
(reply) => {
|
||||
if (wasWatching) plugin.touch();
|
||||
return context.newString(reply);
|
||||
},
|
||||
(err: unknown) => {
|
||||
if (wasWatching) plugin.touch();
|
||||
return context.newError(err instanceof Error ? err.message : String(err));
|
||||
},
|
||||
);
|
||||
|
||||
const deferred = context.newPromise(settle);
|
||||
void deferred.settled.then(() => {
|
||||
this.pump(plugin);
|
||||
deferred.dispose();
|
||||
});
|
||||
return deferred.handle;
|
||||
});
|
||||
}
|
||||
|
||||
private pump(plugin: LoadedPlugin): void {
|
||||
const result = this.runtime?.executePendingJobs();
|
||||
if (result?.error != null) {
|
||||
this.onLog({
|
||||
pluginRefId: plugin.pluginRefId,
|
||||
level: "error",
|
||||
message: `Unhandled error in sandbox: ${result.error.consume(
|
||||
plugin.context.dump.bind(plugin.context),
|
||||
)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private evalOrThrow(plugin: LoadedPlugin, source: string, filename: string): void {
|
||||
plugin.deadline = Date.now() + SYNC_BUDGET_MS;
|
||||
try {
|
||||
const result = plugin.context.evalCode(source, filename);
|
||||
if (result.error != null) {
|
||||
throw this.toError(plugin, result.error.consume(plugin.context.dump.bind(plugin.context)));
|
||||
}
|
||||
result.value.dispose();
|
||||
} finally {
|
||||
plugin.deadline = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async callGuest(
|
||||
plugin: LoadedPlugin,
|
||||
method: string,
|
||||
args: (string | number)[],
|
||||
// oxlint-disable-next-line no-explicit-any -- the caller knows the guest's shape
|
||||
): Promise<any> {
|
||||
const { context } = plugin;
|
||||
plugin.deadline = Date.now() + SYNC_BUDGET_MS;
|
||||
|
||||
const guest = context.getProp(context.global, "__yaak_guest");
|
||||
const fn = context.getProp(guest, method);
|
||||
const argHandles = args.map((a) =>
|
||||
typeof a === "string" ? context.newString(a) : context.newNumber(a),
|
||||
);
|
||||
|
||||
try {
|
||||
const called = context.callFunction(fn, guest, ...argHandles);
|
||||
if (called.error != null) {
|
||||
throw this.toError(plugin, called.error.consume(context.dump.bind(context)));
|
||||
}
|
||||
|
||||
const value = called.value;
|
||||
const state = context.getPromiseState(value);
|
||||
if (state.type !== "fulfilled" || state.notAPromise !== true) {
|
||||
const resolved = context.resolvePromise(value);
|
||||
value.dispose();
|
||||
this.pump(plugin);
|
||||
const settled = await resolved;
|
||||
if (settled.error != null) {
|
||||
throw this.toError(plugin, settled.error.consume(context.dump.bind(context)));
|
||||
}
|
||||
return settled.value.consume(context.dump.bind(context));
|
||||
}
|
||||
|
||||
return value.consume(context.dump.bind(context));
|
||||
} finally {
|
||||
plugin.deadline = null;
|
||||
for (const handle of argHandles) handle.dispose();
|
||||
fn.dispose();
|
||||
guest.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private callGuestSync(plugin: LoadedPlugin, method: string, args: number[]): void {
|
||||
const { context } = plugin;
|
||||
const guest = context.getProp(context.global, "__yaak_guest");
|
||||
const fn = context.getProp(guest, method);
|
||||
const argHandles = args.map((a) => context.newNumber(a));
|
||||
try {
|
||||
const called = context.callFunction(fn, guest, ...argHandles);
|
||||
if (called.error != null) {
|
||||
this.onLog({
|
||||
pluginRefId: plugin.pluginRefId,
|
||||
level: "error",
|
||||
message: String(this.toError(plugin, called.error.consume(context.dump.bind(context)))),
|
||||
});
|
||||
} else {
|
||||
called.value.dispose();
|
||||
}
|
||||
} finally {
|
||||
for (const handle of argHandles) handle.dispose();
|
||||
fn.dispose();
|
||||
guest.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private toError(plugin: LoadedPlugin, dumped: unknown): Error {
|
||||
if (dumped != null && typeof dumped === "object") {
|
||||
const { message, name, stack } = dumped as Record<string, string | undefined>;
|
||||
const error = new Error(message ?? JSON.stringify(dumped));
|
||||
if (name != null) error.name = name;
|
||||
if (stack != null) error.stack = `${name ?? "Error"}: ${message ?? ""}\n${stack}`;
|
||||
return error;
|
||||
}
|
||||
// An interrupted plugin surfaces as `null` with no error object.
|
||||
if (dumped == null) {
|
||||
return new Error(
|
||||
`Plugin \`${plugin.pluginRefId}\` was stopped after running for ` +
|
||||
`${SYNC_BUDGET_MS / 1000}s without yielding`,
|
||||
);
|
||||
}
|
||||
return new Error(typeof dumped === "string" ? dumped : JSON.stringify(dumped));
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* A tab's handle on its sandbox. `onHostRequest` is the entire answer to "what
|
||||
* can a plugin do here?", and this package deliberately has no opinion on it.
|
||||
*/
|
||||
|
||||
import type { FromSandbox, ToSandbox } from "./protocol";
|
||||
|
||||
|
||||
/** Answers one `ctx` call: JSON envelope in, JSON reply out. */
|
||||
export type HostRequestHandler = (envelope: string) => Promise<string>;
|
||||
|
||||
export interface PluginSandboxOptions {
|
||||
onHostRequest: HostRequestHandler;
|
||||
onLog?: (log: { pluginRefId: string; level: string; message: string }) => void;
|
||||
}
|
||||
|
||||
export interface PluginSummary {
|
||||
templateFunctions: string[];
|
||||
authentication: string | null;
|
||||
importer: boolean;
|
||||
filter: boolean;
|
||||
themes: number;
|
||||
httpRequestActions: number;
|
||||
workspaceActions: number;
|
||||
folderActions: number;
|
||||
grpcRequestActions: number;
|
||||
websocketRequestActions: number;
|
||||
}
|
||||
|
||||
type Pending = { resolve: (value: unknown) => void; reject: (reason: Error) => void };
|
||||
|
||||
export class PluginSandbox {
|
||||
private readonly worker: Worker;
|
||||
private readonly pending = new Map<number, Pending>();
|
||||
private readonly options: PluginSandboxOptions;
|
||||
private nextId = 1;
|
||||
|
||||
constructor(options: PluginSandboxOptions) {
|
||||
this.options = options;
|
||||
|
||||
// Written inline because that exact syntax is what the bundler
|
||||
// pattern-matches; hoisted into a variable it ships as raw TypeScript.
|
||||
this.worker = new Worker(new URL("./worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
name: "yaak-plugins",
|
||||
});
|
||||
this.worker.onmessage = (e: MessageEvent<FromSandbox>) => this.receive(e.data);
|
||||
this.worker.onerror = () => this.failEverything("The plugin sandbox failed to start");
|
||||
}
|
||||
|
||||
load(pluginRefId: string, source: string): Promise<PluginSummary> {
|
||||
return this.request<PluginSummary>((id) => ({ type: "load", id, pluginRefId, source }));
|
||||
}
|
||||
|
||||
unload(pluginRefId: string): Promise<void> {
|
||||
return this.request<void>((id) => ({ type: "unload", id, pluginRefId }));
|
||||
}
|
||||
|
||||
async dispatch<T>(
|
||||
pluginRefId: string,
|
||||
context: unknown,
|
||||
payload: unknown,
|
||||
): Promise<T & { type: string }> {
|
||||
const envelope = JSON.stringify({ context, payload });
|
||||
const reply = await this.request<string>((id) => ({
|
||||
type: "dispatch",
|
||||
id,
|
||||
pluginRefId,
|
||||
envelope,
|
||||
}));
|
||||
const parsed = JSON.parse(reply) as { type: string; error?: string };
|
||||
if (parsed.type === "error_response") {
|
||||
throw new Error(parsed.error || "Plugin failed");
|
||||
}
|
||||
return parsed as T & { type: string };
|
||||
}
|
||||
|
||||
/** `terminate()`, not a polite shutdown: the reason to call this is a plugin that won't stop. */
|
||||
dispose(): void {
|
||||
this.worker.terminate();
|
||||
this.failEverything("The plugin sandbox was shut down");
|
||||
}
|
||||
|
||||
/* ------------------------------ internals ------------------------------- */
|
||||
|
||||
private request<T>(build: (id: number) => ToSandbox): Promise<T> {
|
||||
const id = this.nextId++;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
|
||||
this.worker.postMessage(build(id));
|
||||
});
|
||||
}
|
||||
|
||||
private receive(message: FromSandbox): void {
|
||||
switch (message.type) {
|
||||
case "result": {
|
||||
const p = this.pending.get(message.id);
|
||||
this.pending.delete(message.id);
|
||||
p?.resolve(message.result);
|
||||
return;
|
||||
}
|
||||
case "error": {
|
||||
const p = this.pending.get(message.id);
|
||||
this.pending.delete(message.id);
|
||||
p?.reject(new Error(message.message));
|
||||
return;
|
||||
}
|
||||
case "log":
|
||||
this.options.onLog?.(message);
|
||||
return;
|
||||
case "host_call":
|
||||
void this.answer(message.id, message.envelope);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async answer(id: number, envelope: string): Promise<void> {
|
||||
let reply: ToSandbox;
|
||||
try {
|
||||
reply = { type: "host_result", id, reply: await this.options.onHostRequest(envelope) };
|
||||
} catch (err) {
|
||||
reply = {
|
||||
type: "host_result",
|
||||
id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
this.worker.postMessage(reply);
|
||||
}
|
||||
|
||||
private failEverything(message: string): void {
|
||||
for (const [id, p] of this.pending) {
|
||||
this.pending.delete(id);
|
||||
p.reject(new Error(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Two request/reply flows in opposite directions. Payloads are JSON strings
|
||||
* rather than objects because they must be strings to cross into QuickJS
|
||||
* anyway, so structured-cloning them first would only be undone.
|
||||
*/
|
||||
|
||||
/** Tab → worker */
|
||||
export type ToSandbox =
|
||||
| { type: "load"; id: number; pluginRefId: string; source: string }
|
||||
| { type: "unload"; id: number; pluginRefId: string }
|
||||
| { type: "dispatch"; id: number; pluginRefId: string; envelope: string }
|
||||
/** The tab's answer to a `host_call`. */
|
||||
| { type: "host_result"; id: number; reply?: string; error?: string };
|
||||
|
||||
/** Worker → tab */
|
||||
export type FromSandbox =
|
||||
| { type: "result"; id: number; result: unknown }
|
||||
| { type: "error"; id: number; message: string }
|
||||
/** A plugin wants something only the tab can provide. */
|
||||
| { type: "host_call"; id: number; envelope: string }
|
||||
| { type: "log"; pluginRefId: string; level: string; message: string };
|
||||
@@ -1,68 +0,0 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
/**
|
||||
* A dedicated worker owned by the tab, deliberately not the SharedWorker that
|
||||
* owns the database. Reasons and the cost are in the README.
|
||||
*/
|
||||
|
||||
import { PluginSandboxHost } from "./host/sandbox";
|
||||
import type { FromSandbox, ToSandbox } from "./protocol";
|
||||
|
||||
const scope = self as unknown as DedicatedWorkerGlobalScope;
|
||||
|
||||
function send(message: FromSandbox): void {
|
||||
scope.postMessage(message);
|
||||
}
|
||||
|
||||
const pendingHostCalls = new Map<number, (reply: string | Error) => void>();
|
||||
let nextHostCallId = 1;
|
||||
|
||||
const host = new PluginSandboxHost(
|
||||
(envelope) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const id = nextHostCallId++;
|
||||
pendingHostCalls.set(id, (reply) => (reply instanceof Error ? reject(reply) : resolve(reply)));
|
||||
send({ type: "host_call", id, envelope });
|
||||
}),
|
||||
(log) => send({ type: "log", ...log }),
|
||||
);
|
||||
|
||||
async function handle(message: ToSandbox): Promise<void> {
|
||||
if (message.type === "host_result") {
|
||||
const settle = pendingHostCalls.get(message.id);
|
||||
pendingHostCalls.delete(message.id);
|
||||
settle?.(message.error != null ? new Error(message.error) : (message.reply ?? "{}"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (message.type) {
|
||||
case "load":
|
||||
send({
|
||||
type: "result",
|
||||
id: message.id,
|
||||
result: await host.load(message.pluginRefId, message.source),
|
||||
});
|
||||
return;
|
||||
case "unload":
|
||||
host.unload(message.pluginRefId);
|
||||
send({ type: "result", id: message.id, result: null });
|
||||
return;
|
||||
case "dispatch":
|
||||
send({
|
||||
type: "result",
|
||||
id: message.id,
|
||||
result: await host.dispatch(message.pluginRefId, message.envelope),
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "error",
|
||||
id: message.id,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
scope.onmessage = (e: MessageEvent<ToSandbox>) => void handle(e.data);
|
||||
@@ -11,7 +11,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "yaakcli build",
|
||||
"dev": "yaakcli dev"
|
||||
"dev": "yaakcli dev",
|
||||
"test": "vp test --run tests"
|
||||
},
|
||||
"dependencies": {
|
||||
"oauth-1.0a": "^2.2.6"
|
||||
|
||||
@@ -161,7 +161,7 @@ export const plugin: PluginDefinition = {
|
||||
if (values.timestamp) requestData.data.oauth_timestamp = String(values.timestamp);
|
||||
if (values.verifier) requestData.data.oauth_verifier = String(values.verifier);
|
||||
|
||||
let token: OAuth.Token | { key: string } | undefined;
|
||||
let token: OAuth.Token | { key: string } | { secret: string } | undefined;
|
||||
|
||||
if (pkSigs.includes(signatureMethod)) {
|
||||
token = {
|
||||
@@ -172,6 +172,10 @@ export const plugin: PluginDefinition = {
|
||||
token = { key: String(values.tokenKey), secret: String(values.tokenSecret) };
|
||||
} else if (values.tokenKey) {
|
||||
token = { key: String(values.tokenKey) };
|
||||
} else if (values.tokenSecret) {
|
||||
// The secret still joins the signing key without an access token;
|
||||
// leaving `key` out keeps oauth_token out of the header entirely
|
||||
token = { secret: String(values.tokenSecret) };
|
||||
}
|
||||
|
||||
const authParams = oauth.authorize(requestData, token as OAuth.Token | undefined);
|
||||
@@ -202,7 +206,10 @@ function hashFunction(signatureMethod: SigMethod) {
|
||||
return (base: string, privateKey: string) =>
|
||||
crypto.createSign("RSA-SHA512").update(base).sign(privateKey, "base64");
|
||||
case signatures.PLAINTEXT:
|
||||
return (base: string) => base;
|
||||
// RFC 5849 3.4.4: the PLAINTEXT signature IS the signing key,
|
||||
// `encoded(consumer secret)&encoded(token secret)`. Returning the base
|
||||
// string put the whole percent-encoded request into oauth_signature.
|
||||
return (_base: string, key: string) => key;
|
||||
default:
|
||||
return (base: string, key: string) =>
|
||||
crypto.createHmac("sha1", key).update(base).digest("base64");
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { plugin } from "../src";
|
||||
|
||||
function sign(values: Record<string, string>): string {
|
||||
const result = plugin.authentication!.onApply!(
|
||||
{} as never,
|
||||
{
|
||||
values,
|
||||
method: "GET",
|
||||
url: "https://api.example.com/resource",
|
||||
} as never,
|
||||
) as { setHeaders: { name: string; value: string }[] };
|
||||
const header = result.setHeaders[0]!.value;
|
||||
const match = header.match(/oauth_signature="([^"]*)"/);
|
||||
return decodeURIComponent(match![1]!);
|
||||
}
|
||||
|
||||
describe("PLAINTEXT signature", () => {
|
||||
const base = {
|
||||
signatureMethod: "PLAINTEXT",
|
||||
consumerKey: "ck",
|
||||
consumerSecret: "cs",
|
||||
nonce: "abc123",
|
||||
timestamp: "1700000000",
|
||||
};
|
||||
|
||||
// RFC 5849 3.4.4: the PLAINTEXT signature is the signing key itself --
|
||||
// encoded(consumer secret) "&" encoded(token secret) -- not the base string.
|
||||
test("is the signing key, not the signature base string", () => {
|
||||
expect(sign({ ...base, tokenKey: "tk", tokenSecret: "ts" })).toBe("cs&ts");
|
||||
});
|
||||
|
||||
test("keeps the trailing separator when there is no token secret", () => {
|
||||
expect(sign(base)).toBe("cs&");
|
||||
});
|
||||
|
||||
test("includes the token secret without an access token, omitting oauth_token", () => {
|
||||
const result = plugin.authentication!.onApply!(
|
||||
{} as never,
|
||||
{
|
||||
values: { ...base, tokenSecret: "ts" },
|
||||
method: "GET",
|
||||
url: "https://api.example.com/resource",
|
||||
} as never,
|
||||
) as { setHeaders: { name: string; value: string }[] };
|
||||
const header = result.setHeaders[0]!.value;
|
||||
const match = header.match(/oauth_signature="([^"]*)"/);
|
||||
expect(decodeURIComponent(match![1]!)).toBe("cs&ts");
|
||||
expect(header).not.toContain("oauth_token=");
|
||||
});
|
||||
|
||||
test("percent-encodes reserved characters in the secrets", () => {
|
||||
expect(sign({ ...base, consumerSecret: "c s", tokenKey: "tk", tokenSecret: "t&s" })).toBe(
|
||||
"c%20s&t%26s",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -359,7 +359,9 @@ function importCommand(parseEntries: string[], workspaceId: string) {
|
||||
if (typeof p !== "string") {
|
||||
continue;
|
||||
}
|
||||
const [name, value] = p.split("=");
|
||||
// splitOnce: a query value may itself contain "=" (a base64 payload, a
|
||||
// nested filter), and only the first one separates name from value.
|
||||
const [name, value] = splitOnce(p, "=");
|
||||
urlParameters.push({
|
||||
name: name ?? "",
|
||||
value: value ?? "",
|
||||
@@ -475,7 +477,9 @@ function importCommand(parseEntries: string[], workspaceId: string) {
|
||||
...((flagsByName.form as string[] | undefined) || []),
|
||||
...((flagsByName.F as string[] | undefined) || []),
|
||||
].map((str) => {
|
||||
const parts = str.split("=");
|
||||
// splitOnce for the same reason as --url-query above: base64 padding
|
||||
// ("...==") and any value containing "=" must survive intact.
|
||||
const parts = splitOnce(str, "=");
|
||||
const name = parts[0] ?? "";
|
||||
const value = parts[1] ?? "";
|
||||
const item: { name: string; value?: string; file?: string; enabled: boolean } = {
|
||||
|
||||
@@ -942,6 +942,23 @@ describe("importer-curl", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
test("Keeps an = inside a --url-query value", () => {
|
||||
const imported = convertCurl(
|
||||
'curl --url-query "filter=type=book" --url-query "t=eyJhIjoxfQ==" https://yaak.app',
|
||||
);
|
||||
expect(imported.resources.httpRequests?.[0]?.urlParameters).toEqual([
|
||||
{ enabled: true, name: "filter", value: "type=book" },
|
||||
{ enabled: true, name: "t", value: "eyJhIjoxfQ==" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Keeps an = inside a form value", () => {
|
||||
const imported = convertCurl('curl -F "t=eyJhIjoxfQ==" -F "q=a=b" https://yaak.app');
|
||||
expect(imported.resources.httpRequests?.[0]?.body?.form).toEqual([
|
||||
{ enabled: true, name: "t", value: "eyJhIjoxfQ==" },
|
||||
{ enabled: true, name: "q", value: "a=b" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const idCount: Partial<Record<string, number>> = {};
|
||||
|
||||
@@ -25,7 +25,7 @@ type ImportedAuthentication = Pick<HttpRequest, "authentication" | "authenticati
|
||||
urlParameters: HttpUrlParameter[];
|
||||
};
|
||||
type AuthenticationVariableRegistry = Map<string, { name: string; value: string }>;
|
||||
type OAuthVariableNames = { clientId: string; clientSecret: string };
|
||||
type OAuthVariableNames = { clientId: string; clientSecret: string; redirectUri: string };
|
||||
type ServerOverrideVariable = { name: string; value: string };
|
||||
|
||||
const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "query", "trace"];
|
||||
@@ -172,6 +172,18 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
||||
clientSecret,
|
||||
]),
|
||||
);
|
||||
// Only redirect-based grants reference the redirect variable, so don't
|
||||
// create it for specs that import as client_credentials or password
|
||||
for (const { redirectUri } of oauthVariablesByScheme.values()) {
|
||||
const used = authenticationConfigs.some(
|
||||
(model) =>
|
||||
model.authenticationType === "oauth2" &&
|
||||
Object.values(toRecord(model.authentication)).some(
|
||||
(value) => typeof value === "string" && value.includes(templateVariable(redirectUri)),
|
||||
),
|
||||
);
|
||||
if (used) variableNames.add(redirectUri);
|
||||
}
|
||||
if (
|
||||
authenticationConfigs.some(
|
||||
(model) =>
|
||||
@@ -565,7 +577,10 @@ function importOperationName(operation: UnknownRecord, method: string, path: str
|
||||
* description, since a name that long is no easier to scan than the path.
|
||||
*/
|
||||
function firstLine(value: string | undefined): string | undefined {
|
||||
const line = value?.split("\n").find((l) => l.trim().length > 0)?.trim();
|
||||
const line = value
|
||||
?.split("\n")
|
||||
.find((l) => l.trim().length > 0)
|
||||
?.trim();
|
||||
if (line == null || line.length > MAX_NAME_LENGTH) return undefined;
|
||||
return line;
|
||||
}
|
||||
@@ -738,8 +753,7 @@ function shouldInlinePathParameter(
|
||||
// so `{id}` and `{id}:cancel` stay placeholders while `report.{format}` can't
|
||||
const placeholderExpressible = matchingSegments.every(
|
||||
(segment) =>
|
||||
segment === template ||
|
||||
(segment.startsWith(template) && segment[template.length] === ":"),
|
||||
segment === template || (segment.startsWith(template) && segment[template.length] === ":"),
|
||||
);
|
||||
if (matchingSegments.length === 0 || !placeholderExpressible) return true;
|
||||
if (isRecord(parameter.content)) return false;
|
||||
@@ -1009,9 +1023,7 @@ function serializeCookieParameter(parameter: UnknownRecord, importState: ImportS
|
||||
if (isRecord(value)) {
|
||||
const entries = Object.entries(value);
|
||||
return explode
|
||||
? entries
|
||||
.map(([key, entryValue]) => `${key}=${stringifyExampleValue(entryValue)}`)
|
||||
.join("; ")
|
||||
? entries.map(([key, entryValue]) => `${key}=${stringifyExampleValue(entryValue)}`).join("; ")
|
||||
: `${name}=${entries.flat().map(stringifyExampleValue).join(",")}`;
|
||||
}
|
||||
return `${name}=${stringifyExampleValue(value)}`;
|
||||
@@ -1158,7 +1170,9 @@ function importBody({
|
||||
.filter((p) => stringAt(p, "in") === "formData");
|
||||
if (formParameters.length > 0) {
|
||||
const contentType =
|
||||
toArray(operation.consumes ?? spec.consumes).find((c): c is string => typeof c === "string") ??
|
||||
toArray(operation.consumes ?? spec.consumes).find(
|
||||
(c): c is string => typeof c === "string",
|
||||
) ??
|
||||
(formParameters.some((p) => stringAt(p, "type") === "file")
|
||||
? "multipart/form-data"
|
||||
: "application/x-www-form-urlencoded");
|
||||
@@ -1411,7 +1425,11 @@ function mediaTypeExample(mediaType: UnknownRecord, importState: ImportState): u
|
||||
return schemaToExample(mediaType.schema, importState);
|
||||
}
|
||||
|
||||
function schemaToFormParameters(schema: unknown, importState: ImportState, example?: UnknownRecord) {
|
||||
function schemaToFormParameters(
|
||||
schema: unknown,
|
||||
importState: ImportState,
|
||||
example?: UnknownRecord,
|
||||
) {
|
||||
const resolvedSchema = toRecord(importState.resolveSchema(schema));
|
||||
const required = toArray(resolvedSchema.required).filter(
|
||||
(name): name is string => typeof name === "string",
|
||||
@@ -1575,7 +1593,6 @@ function coerceToDeclaredType(example: unknown, schema: UnknownRecord): unknown
|
||||
return example;
|
||||
}
|
||||
|
||||
|
||||
function inferSchemaType(schema: UnknownRecord): string {
|
||||
const rawType = schema.type;
|
||||
if (typeof rawType === "string") return rawType;
|
||||
@@ -1688,6 +1705,7 @@ function importSecurityRequirement({
|
||||
oauthVariablesByScheme.get(schemeName) ?? {
|
||||
clientId: "oauth_client_id",
|
||||
clientSecret: "oauth_client_secret",
|
||||
redirectUri: "oauth_redirect_uri",
|
||||
},
|
||||
useDynamicServerUrls,
|
||||
);
|
||||
@@ -1876,9 +1894,13 @@ function importOAuth2(
|
||||
authorizationUrl,
|
||||
accessTokenUrl,
|
||||
clientSecret: templateVariable(variableNames.clientSecret),
|
||||
redirectUri: templateVariable(variableNames.redirectUri),
|
||||
}
|
||||
: grantType === "implicit"
|
||||
? { authorizationUrl }
|
||||
? {
|
||||
authorizationUrl,
|
||||
redirectUri: templateVariable(variableNames.redirectUri),
|
||||
}
|
||||
: grantType === "password"
|
||||
? {
|
||||
accessTokenUrl,
|
||||
@@ -1969,7 +1991,11 @@ function buildOAuthVariablesByScheme(
|
||||
usedPrefixes.add(prefix);
|
||||
return [
|
||||
schemeName,
|
||||
{ clientId: `${prefix}_client_id`, clientSecret: `${prefix}_client_secret` },
|
||||
{
|
||||
clientId: `${prefix}_client_id`,
|
||||
clientSecret: `${prefix}_client_secret`,
|
||||
redirectUri: `${prefix}_redirect_uri`,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -384,6 +384,7 @@ describe("importer-openapi", () => {
|
||||
{ name: "baseUrl", value: "https://api.example.com/v1" },
|
||||
{ name: "oauth_client_id", value: "" },
|
||||
{ name: "oauth_client_secret", value: "" },
|
||||
{ name: "oauth_redirect_uri", value: "" },
|
||||
{ name: "baseUrlOrigin", value: "https://api.example.com" },
|
||||
{ name: "auth_api_key_key", value: "" },
|
||||
],
|
||||
@@ -395,6 +396,7 @@ describe("importer-openapi", () => {
|
||||
{ name: "baseUrl", value: "https://sandbox.example.com/v1" },
|
||||
{ name: "oauth_client_id", value: "" },
|
||||
{ name: "oauth_client_secret", value: "" },
|
||||
{ name: "oauth_redirect_uri", value: "" },
|
||||
{ name: "baseUrlOrigin", value: "https://sandbox.example.com" },
|
||||
{ name: "auth_api_key_key", value: "" },
|
||||
],
|
||||
@@ -484,6 +486,7 @@ describe("importer-openapi", () => {
|
||||
clientId: "${[oauth_implicitOauth_client_id]}",
|
||||
headerPrefix: "Bearer",
|
||||
authorizationUrl: "https://example.com/authorize",
|
||||
redirectUri: "${[oauth_implicitOauth_redirect_uri]}",
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -497,6 +500,7 @@ describe("importer-openapi", () => {
|
||||
{ name: "oauth_oauth_client_secret", value: "" },
|
||||
{ name: "oauth_implicitOauth_client_id", value: "" },
|
||||
{ name: "oauth_implicitOauth_client_secret", value: "" },
|
||||
{ name: "oauth_implicitOauth_redirect_uri", value: "" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -539,6 +543,7 @@ describe("importer-openapi", () => {
|
||||
{ name: "baseUrl", value: "https://api.example.com" },
|
||||
{ name: "oauth_client_id", value: "" },
|
||||
{ name: "oauth_client_secret", value: "" },
|
||||
{ name: "oauth_redirect_uri", value: "" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
@@ -596,6 +601,7 @@ describe("importer-openapi", () => {
|
||||
{ name: "baseUrl", value: "/api/v1" },
|
||||
{ name: "oauth_client_id", value: "" },
|
||||
{ name: "oauth_client_secret", value: "" },
|
||||
{ name: "oauth_redirect_uri", value: "" },
|
||||
{ name: "baseUrlOrigin", value: "" },
|
||||
],
|
||||
}),
|
||||
@@ -633,6 +639,7 @@ describe("importer-openapi", () => {
|
||||
scope: "admin",
|
||||
authorizationUrl: "https://example.com/authorize",
|
||||
accessTokenUrl: "https://example.com/token",
|
||||
redirectUri: "${[oauth_redirect_uri]}",
|
||||
},
|
||||
headers: [{ enabled: true, name: "Accept", value: "application/json" }],
|
||||
}),
|
||||
@@ -1376,9 +1383,7 @@ describe("importer-openapi", () => {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
allOf: [
|
||||
{ type: "object", properties: { fromAllOf: { example: "a" } } },
|
||||
],
|
||||
allOf: [{ type: "object", properties: { fromAllOf: { example: "a" } } }],
|
||||
properties: { sibling: { example: "b" } },
|
||||
},
|
||||
},
|
||||
@@ -1398,7 +1403,17 @@ describe("importer-openapi", () => {
|
||||
|
||||
test("Accepts unquoted YAML version numbers", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
["swagger: 2.0", "info:", " title: Unquoted Test", ' version: "1"', "host: example.com", "paths:", " /a:", " get:", " responses: {}"].join("\n"),
|
||||
[
|
||||
"swagger: 2.0",
|
||||
"info:",
|
||||
" title: Unquoted Test",
|
||||
' version: "1"',
|
||||
"host: example.com",
|
||||
"paths:",
|
||||
" /a:",
|
||||
" get:",
|
||||
" responses: {}",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.url).toBe("${[baseUrl]}/a");
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "yaakcli build",
|
||||
"dev": "yaakcli dev"
|
||||
"dev": "yaakcli dev",
|
||||
"test": "vp test --run tests"
|
||||
},
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "^10.3.0"
|
||||
|
||||
@@ -85,7 +85,11 @@ export const plugin: PluginDefinition = {
|
||||
],
|
||||
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
|
||||
const input = String(args.values.input ?? "");
|
||||
return input.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
// JSON.stringify produces a spec-correct string literal: it escapes
|
||||
// the backslash and quote this used to handle, and also the control
|
||||
// characters it did not. Slicing off the surrounding quotes leaves
|
||||
// the escaped inner text this function is meant to emit.
|
||||
return JSON.stringify(input).slice(1, -1);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Context } from "@yaakapp/api";
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
import { plugin } from "../src";
|
||||
|
||||
const LF = String.fromCharCode(10);
|
||||
const TAB = String.fromCharCode(9);
|
||||
const CR = String.fromCharCode(13);
|
||||
|
||||
describe("json.escape", () => {
|
||||
const escapeFunction = plugin.templateFunctions?.find((f) => f.name === "json.escape");
|
||||
|
||||
const escape = async (input: string) =>
|
||||
await escapeFunction!.onRender({} as Context, { values: { input } } as never);
|
||||
|
||||
// The point of the function is that the result can be dropped between two
|
||||
// quotes in a JSON document, so that is what these assert.
|
||||
const embeds = (escaped: string | null) => {
|
||||
JSON.parse(`{"k":"${escaped}"}`);
|
||||
return JSON.parse(`{"k":"${escaped}"}`).k;
|
||||
};
|
||||
|
||||
it("should exist", () => {
|
||||
expect(escapeFunction).toBeTruthy();
|
||||
});
|
||||
|
||||
it("escapes a quote", async () => {
|
||||
const input = `say "hi"`;
|
||||
expect(embeds(await escape(input))).toBe(input);
|
||||
});
|
||||
|
||||
it("escapes a backslash", async () => {
|
||||
const input = `a${String.fromCharCode(92)}b`;
|
||||
expect(embeds(await escape(input))).toBe(input);
|
||||
});
|
||||
|
||||
it("escapes a newline", async () => {
|
||||
const input = `line1${LF}line2`;
|
||||
expect(embeds(await escape(input))).toBe(input);
|
||||
});
|
||||
|
||||
it("escapes a tab and a carriage return", async () => {
|
||||
const input = `a${TAB}b${CR}c`;
|
||||
expect(embeds(await escape(input))).toBe(input);
|
||||
});
|
||||
|
||||
it("round-trips a pretty-printed JSON document", async () => {
|
||||
const input = JSON.stringify({ name: `he said "hi"`, items: [1, 2] }, null, 2);
|
||||
expect(embeds(await escape(input))).toBe(input);
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* A stand-in for `yaakcli build --target sandbox`, which does not exist yet.
|
||||
* What the CLI would need instead is at the bottom of this file.
|
||||
*/
|
||||
|
||||
import { build } from "esbuild";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
/** Three, not the corpus: one template function, one importer, one auth method. */
|
||||
const PLUGINS = ["template-function-timestamp", "importer-curl", "auth-bearer"];
|
||||
|
||||
const noNodeBuiltins = {
|
||||
name: "no-node-builtins",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^(node:|fs$|path$|crypto$|buffer$|process$|os$|util$|stream$)/ }, (args) => ({
|
||||
errors: [
|
||||
{
|
||||
text:
|
||||
`\`${args.path}\` is not available in the sandbox runtime. ` +
|
||||
`Replace it with a pure-JavaScript equivalent.`,
|
||||
},
|
||||
],
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
export async function bundlePlugin(name, { dir = join(root, "plugins", name) } = {}) {
|
||||
const result = await build({
|
||||
entryPoints: [join(dir, "src", "index.ts")],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: "cjs",
|
||||
platform: "browser",
|
||||
target: "es2022",
|
||||
minify: false,
|
||||
legalComments: "none",
|
||||
plugins: [noNodeBuiltins],
|
||||
});
|
||||
return result.outputFiles[0].text;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const bundles = [];
|
||||
for (const name of PLUGINS) {
|
||||
const source = await bundlePlugin(name);
|
||||
bundles.push({ name, source });
|
||||
console.log(`${name}: ${(source.length / 1024).toFixed(1)} KB`);
|
||||
}
|
||||
|
||||
const outFile = join(root, "packages", "platform", "src", "web", "sandboxPlugins.generated.ts");
|
||||
mkdirSync(dirname(outFile), { recursive: true });
|
||||
writeFileSync(
|
||||
outFile,
|
||||
[
|
||||
"// Generated by scripts/bundle-sandbox-plugins.mjs. Do not edit.",
|
||||
"//",
|
||||
"// The plugins the browser tier loads into its sandbox, bundled for that",
|
||||
"// target and inlined as source text.",
|
||||
"",
|
||||
"export interface SandboxPluginBundle {",
|
||||
" name: string;",
|
||||
" source: string;",
|
||||
"}",
|
||||
"",
|
||||
"export const SANDBOX_PLUGINS: SandboxPluginBundle[] = [",
|
||||
...bundles.map((b) => ` { name: ${JSON.stringify(b.name)}, source: ${JSON.stringify(b.source)} },`),
|
||||
"];",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
console.log(`Wrote ${outFile}`);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) await main();
|
||||
|
||||
/*
|
||||
* What `yaakcli build --target sandbox` would need, beyond this:
|
||||
*
|
||||
* 1. `Platform::Browser` in the rolldown options (crates-cli/yaak-cli/src/
|
||||
* commands/plugin.rs `bundler_options`), plus a resolver that fails on a
|
||||
* Node built-in instead of shimming it — a silent shim turns a missing
|
||||
* capability into a runtime error inside someone else's plugin.
|
||||
* 2. A `runtime` field in the plugin manifest, so a plugin declares which
|
||||
* target it is for and the registry can refuse to install a `node` plugin
|
||||
* on a host that has no Node.
|
||||
* 3. Both targets emitted for the same source where they both work, since a
|
||||
* desktop with a sandbox and a desktop with Node are the same install.
|
||||
* 4. Distribution as files, not as inlined strings. Inlining is what this
|
||||
* script does because three small bundles cost less than an asset pipeline;
|
||||
* the corpus does not, and a plugin the user installs at runtime cannot be
|
||||
* inlined at build time at all.
|
||||
*/
|
||||
Reference in New Issue
Block a user