Compare commits

..
Author SHA1 Message Date
Gregory Schier 19c753c433 Merge branch 'main' into claude/serene-dewdney-4c1527 2026-08-20 09:06:44 -07:00
Gregory Schier 252b151fcb Follow the yaak-web to yaak-wasm rename
Rebased onto main after #572 merged. The wasm crate moved to crates/yaak-wasm
and crates-server/yaak-web is now the server, so the render_template export,
the regenerated pkg/ and the proxy/server wording move with them.
2026-08-18 15:26:10 -07:00
Gregory Schier 85ba3e6852 Cut comments back to the non-obvious
Rationale that explains a decision rather than the code below it belongs in
the sandbox README or the PR, not in a header paragraph on every file.
2026-08-18 15:23:10 -07:00
Gregory Schier 20c0efc2a5 Build ctx once and share it between both plugin runtimes
The sandbox's context builder was a near-copy of the Node runtime's. Both
now come from createPluginContext in @yaakapp-internal/lib, with each
runtime supplying only a transport.

The two places hosts genuinely differ are optional transport methods:
`stream` (a window reporting navigation until it closes) and `form` (a
prompt that re-renders as values change). The sandbox has neither, so
openUrl refuses and a form is drawn once from its defaults.
2026-08-18 15:22:49 -07:00
Gregory Schier f8d6dfbdaa Run plugins in a QuickJS sandbox in the browser
Adds packages/plugin-sandbox: QuickJS-ng compiled to wasm, running in a
dedicated worker, with a runtime shell inside it that loads a plugin bundle
and answers the same InternalEventPayload events the Node runtime answers.
Plugins are unmodified.

Wires the browser host's template function, authentication, cURL import and
template render commands to it, and relaxes TemplateCallback's Send bound on
wasm32 so the engine's renderer can call back out to a plugin.
2026-08-18 15:22:49 -07:00
145 changed files with 3923 additions and 8664 deletions
+3
View File
@@ -0,0 +1,3 @@
**/bindings/**
**/routeTree.gen.ts
crates/yaak-templates/pkg/**
Generated
+1 -6
View File
@@ -11223,12 +11223,9 @@ version = "0.1.0"
dependencies = [
"async-trait",
"base64 0.22.1",
"chrono",
"log 0.4.29",
"md5 0.8.0",
"rusqlite",
"serde_json",
"sha2",
"tempfile",
"thiserror 2.0.17",
"tokio",
@@ -11726,10 +11723,7 @@ 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",
]
@@ -11781,6 +11775,7 @@ dependencies = [
"console_error_panic_hook",
"js-sys",
"log 0.4.29",
"md5 0.7.0",
"serde",
"serde-wasm-bindgen",
"serde_json",
@@ -67,14 +67,7 @@ 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
? (event) => {
event.preventDefault();
editEnvironment(null);
}
: undefined
}
onClick={subEnvironments.length === 0 ? () => editEnvironment(null) : undefined}
{...buttonProps}
>
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
+62 -179
View File
@@ -1,8 +1,9 @@
import { linter } from "@codemirror/lint";
import type { EditorView } from "@codemirror/view";
import { jsoncLanguage } from "@shopify/lang-jsonc";
import { type GrpcRequest, patchModel } from "@yaakapp-internal/models";
import { Banner, FormattedError, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import type { GrpcRequest } from "@yaakapp-internal/models";
import { FormattedError, InlineCode, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import {
handleRefresh,
jsonCompletion,
@@ -10,20 +11,12 @@ import {
stateExtensions,
updateSchema,
} from "codemirror-json-schema";
import type { JSONSchema7 } from "json-schema";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReflectResponseService } from "../hooks/useGrpc";
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
import { showAlert } from "../lib/alert";
import { showConfirm } from "../lib/confirm";
import { showDialog } from "../lib/dialog";
import type { JsonSchema } from "../lib/jsonSchemaExample";
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
import { pluralizeCount } from "../lib/pluralize";
import { queryClient } from "../lib/queryClient";
import { Button } from "./core/Button";
import { Dropdown } from "./core/Dropdown";
import type { EditorProps } from "./core/Editor/Editor";
import { Editor } from "./core/Editor/LazyEditor";
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
@@ -36,11 +29,6 @@ type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceU
protoFiles: string[];
};
type MethodSchema =
| { type: "none" }
| { type: "schema"; schema: JsonSchema }
| { type: "error"; id: string; title: string; body: ReactNode; log: unknown[] };
export function GrpcEditor({
services,
reflectionError,
@@ -54,16 +42,21 @@ export function GrpcEditor({
setEditorView(h);
}, []);
// Find the schema for the selected service and method
const methodSchema = useMemo<MethodSchema>(() => {
if (services === null || request.service === null || request.method === null) {
return { type: "none" };
// Find the schema for the selected service and method and update the editor
useEffect(() => {
if (
editorView == null ||
services === null ||
request.service === null ||
request.method === null
) {
return;
}
const s = services.find((s) => s.name === request.service);
if (s == null) {
return {
type: "error",
console.log("Failed to find service", { service: request.service, services });
showAlert({
id: "grpc-find-service-error",
title: "Couldn't Find Service",
body: (
@@ -71,14 +64,14 @@ export function GrpcEditor({
Failed to find service <InlineCode>{request.service}</InlineCode> in schema
</>
),
log: ["Failed to find service", { service: request.service, services }],
};
});
return;
}
const schema = s.methods.find((m) => m.name === request.method)?.schema;
if (schema == null) {
return {
type: "error",
if (request.method != null && schema == null) {
console.log("Failed to find method", { method: request.method, methods: s?.methods });
showAlert({
id: "grpc-find-schema-error",
title: "Couldn't Find Method",
body: (
@@ -87,15 +80,18 @@ export function GrpcEditor({
<InlineCode>{request.service}</InlineCode> in schema
</>
),
log: ["Failed to find method", { method: request.method, methods: s.methods }],
};
});
return;
}
if (schema == null) {
return;
}
try {
return { type: "schema", schema: JSON.parse(schema) as JsonSchema };
updateSchema(editorView, JSON.parse(schema));
} catch (err) {
return {
type: "error",
showAlert({
id: "grpc-parse-schema-error",
title: "Failed to Parse Schema",
body: (
@@ -107,22 +103,9 @@ export function GrpcEditor({
<FormattedError>{String(err)}</FormattedError>
</VStack>
),
log: ["Failed to parse schema", err],
};
});
}
}, [services, request.method, request.service]);
useEffect(() => {
if (methodSchema.type !== "error") return;
console.log(...methodSchema.log);
showAlert({ id: methodSchema.id, title: methodSchema.title, body: methodSchema.body });
}, [methodSchema]);
// Update the editor whenever the schema changes
useEffect(() => {
if (editorView == null || methodSchema.type !== "schema") return;
updateSchema(editorView, methodSchema.schema as JSONSchema7);
}, [editorView, methodSchema]);
}, [editorView, services, request.method, request.service]);
const extraExtensions = useMemo(
() => [
@@ -141,145 +124,45 @@ export function GrpcEditor({
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
reflectionError = reflectionUnavailable ? undefined : reflectionError;
const handleGenerateExample = useCallback(async () => {
if (methodSchema.type !== "schema") return;
if (request.message.trim() !== "") {
const confirmed = await showConfirm({
id: "grpc-generate-example",
title: "Generate Example",
description: "The current message will be replaced with an example.",
confirmText: "Generate",
});
if (!confirmed) return;
}
const message = JSON.stringify(buildExampleFromSchema(methodSchema.schema), null, 2);
await patchModel(request, { message });
// Force the editor to pick up the new message
wasUpdatedExternally(request.id);
}, [methodSchema, request]);
// The reflect query is keyed by request, url and proto files, so a prefix invalidate
// reaches it without threading a refetch down from the connection layout.
const handleReloadSchema = useCallback(
() => queryClient.invalidateQueries({ queryKey: ["grpc_reflect", request.id] }),
[request.id],
);
const handleShowReflectionError = useCallback(() => {
showDialog({
id: "grpc-reflection-error",
title: "Reflection Failed",
size: "sm",
render: ({ hide }) => (
<>
<FormattedError>{reflectionError ?? "unknown"}</FormattedError>
<div className="w-full my-4">
<Button
className="ml-auto"
color="primary"
size="sm"
onClick={async () => {
hide();
await handleReloadSchema();
}}
>
Retry
</Button>
</div>
</>
),
});
}, [handleReloadSchema, reflectionError]);
const actions = useMemo(
() => [
// Matches the GraphQL editor: one always-visible control labelled by schema state,
// with everything schema-related behind it.
<div key="schema" className="opacity-100!">
<Dropdown
items={[
{
// Hidden for servers without reflection, which isn't an error
hidden: !reflectionError,
type: "content",
label: (
<Banner color="danger">
<p className="mb-1">Reflection failed</p>
<Button
size="xs"
color="danger"
variant="border"
onClick={handleShowReflectionError}
>
View Error
</Button>
</Banner>
),
},
{
label: "Generate Example Message",
leftSlot: <Icon icon="magic_wand" />,
disabled: methodSchema.type !== "schema",
onSelect: handleGenerateExample,
},
{ type: "separator" },
{
label: "Reload Schema",
leftSlot: <Icon icon="refresh" spin={reflectionLoading} />,
keepOpenOnSelect: true,
onSelect: handleReloadSchema,
},
{
label: protoFiles.length > 0 ? "Select Proto Files\u2026" : "Configure Schema\u2026",
leftSlot: <Icon icon="settings" />,
onSelect: () => {
showDialog({
title: "Configure Schema",
size: "md",
id: "grpc-configure-schema",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
},
},
]}
>
<Button
size="sm"
variant="border"
title="Schema"
forDropdown
isLoading={reflectionLoading}
color={reflectionUnavailable ? "info" : reflectionError ? "danger" : "default"}
>
{reflectionLoading
? "Inspecting Schema"
<div key="reflection" className={classNames(services == null && "opacity-100!")}>
<Button
size="xs"
color={
reflectionLoading
? "secondary"
: reflectionUnavailable
? "Select Proto Files"
? "info"
: reflectionError
? "Server Error"
: protoFiles.length > 0
? pluralizeCount("File", protoFiles.length)
: services != null
? "Schema Detected"
: "Select Schema"}
</Button>
</Dropdown>
? "danger"
: "secondary"
}
isLoading={reflectionLoading}
onClick={() => {
showDialog({
title: "Configure Schema",
size: "md",
id: "reflection-failed",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
}}
>
{reflectionLoading
? "Inspecting Schema"
: reflectionUnavailable
? "Select Proto Files"
: reflectionError
? "Server Error"
: protoFiles.length > 0
? pluralizeCount("File", protoFiles.length)
: services != null && protoFiles.length === 0
? "Schema Detected"
: "Select Schema"}
</Button>
</div>,
],
[
handleGenerateExample,
handleReloadSchema,
handleShowReflectionError,
methodSchema.type,
protoFiles.length,
reflectionError,
reflectionLoading,
reflectionUnavailable,
services,
],
[protoFiles.length, reflectionError, reflectionLoading, reflectionUnavailable, services],
);
return (
@@ -194,6 +194,13 @@ export function GrpcRequestPane({
type: "default",
shortLabel: o.label,
}))}
itemsAfter={[
{
label: "Refresh",
type: "default",
leftSlot: <Icon size="sm" icon="refresh" />,
},
]}
>
<Button
size="sm"
+25 -626
View File
@@ -1,38 +1,15 @@
import {
type Folder,
type ImportDestination,
type ImportPlan,
type ImportPlanItem,
type ImportSource,
type Workspace,
} from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { platform } from "@yaakapp-internal/platform";
import { Icon, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { formatDistanceToNowStrict } from "date-fns";
import { useEffect, useMemo, useRef, useState } from "react";
import { pluralize } from "../lib/pluralize";
import { useEffect, useRef, useState } from "react";
import { useLocalStorage } from "react-use";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import type { CheckboxTreeNode } from "./core/CheckboxTree";
import { CheckboxTree } from "./core/CheckboxTree";
import { IconTooltip } from "./core/IconTooltip";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import { SegmentedControl } from "./core/SegmentedControl";
interface Props {
currentWorkspace: Workspace | null;
workspaces: Workspace[];
selectedFolder: Folder | null;
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
listSources: (workspaceId: string) => Promise<ImportSource[]>;
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
commit: (plan: ImportPlan) => Promise<void>;
cancel: () => void;
onError: (err: unknown) => void;
importFile: (filePath: string) => Promise<void>;
importUrl: (url: string) => Promise<void>;
}
/**
@@ -54,74 +31,10 @@ function fileName(path: string): string {
return path.split(/[/\\]/).at(-1) || path;
}
/**
* Loads the current workspace's linked sources before rendering the dialog, so the inner
* component can construct its initial state (prefilled path, destination) in one pass instead of
* patching it in with effects after the first paint.
*/
export function ImportDataDialog(props: Props) {
const [initialSources, setInitialSources] = useState<ImportSource[] | null>(null);
const { currentWorkspace, listSources } = props;
useEffect(() => {
let cancelled = false;
const load = currentWorkspace == null ? Promise.resolve([]) : listSources(currentWorkspace.id);
load
.then((sources) => {
if (!cancelled) setInitialSources(sources);
})
.catch(() => {
if (!cancelled) setInitialSources([]);
});
return () => {
cancelled = true;
};
}, [currentWorkspace, listSources]);
if (initialSources == null) return null;
return <LoadedImportDataDialog {...props} initialSources={initialSources} />;
}
function latestSource(sources: ImportSource[]): ImportSource | null {
return sources.reduce<ImportSource | null>(
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
null,
);
}
function LoadedImportDataDialog({
currentWorkspace,
workspaces,
selectedFolder,
planFile,
planUrl,
listSources,
findSourcesForOrigin,
commit,
cancel,
onError,
initialSources,
}: Props & { initialSources: ImportSource[] }) {
// A workspace with a linked source is probably being re-imported, so start from that source
const prefill = latestSource(initialSources);
export function ImportDataDialog({ importFile, importUrl }: Props) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [plan, setPlan] = useState<ImportPlan | null>(null);
const [items, setItems] = useState<ImportPlanItem[]>([]);
// null means no explicit choice yet, so the default below applies
const [destinationChoice, setDestinationChoice] = useState<"new" | "current" | "other" | null>(
null,
);
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
const [linkedSources, setLinkedSources] = useState<ImportSource[]>(
prefill != null ? initialSources : [],
);
const [originSources, setOriginSources] = useState<ImportSource[]>(
prefill != null ? [prefill] : [],
);
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
const [source, setSource] = useState<string | null>(prefill?.origin ?? null);
const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", null);
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
const [isHovering, setIsHovering] = useState<boolean>(false);
const ref = useRef<HTMLDivElement>(null);
@@ -152,277 +65,25 @@ function LoadedImportDataDialog({
});
}, [isHovering, setSource]);
useEffect(() => {
if (trimmedSource === "") {
setOriginSources([]);
return;
}
let cancelled = false;
const timeout = setTimeout(() => {
findSourcesForOrigin(filePath != null ? { filePath } : { url: trimmedSource })
.then((sources) => {
if (!cancelled) setOriginSources(sources);
})
.catch(() => setOriginSources([]));
}, 300);
return () => {
cancelled = true;
clearTimeout(timeout);
};
}, [trimmedSource, filePath, findSourcesForOrigin]);
// The one workspace this file is linked to, if there is exactly one.
const linkedWorkspace = useMemo(() => {
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
if (ids.length !== 1) return null;
return workspaces.find((w) => w.id === ids[0]) ?? null;
}, [originSources, workspaces]);
// A file linked to the current workspace defaults back into it, so re-importing doesn't
// accidentally create a duplicate workspace. A file linked elsewhere only gets a suggestion —
// silently targeting a workspace that is neither new nor current is too surprising. An
// explicit choice always wins.
const destinationKind =
destinationChoice ??
(linkedWorkspace != null && linkedWorkspace.id === currentWorkspace?.id ? "current" : "new");
const destinationWorkspaceId =
destinationKind === "current"
? (currentWorkspace?.id ?? null)
: destinationKind === "other"
? otherWorkspaceId
: null;
useEffect(() => {
if (destinationWorkspaceId == null) {
setLinkedSources([]);
return;
}
let cancelled = false;
listSources(destinationWorkspaceId)
.then((sources) => {
if (!cancelled) setLinkedSources(sources);
})
.catch(() => setLinkedSources([]));
return () => {
cancelled = true;
};
}, [destinationWorkspaceId, listSources]);
const handleSelectFile = async () => {
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
if (selected == null) return;
selectSource(selected);
};
// The selected folder belongs to the workspace being viewed, so it is only offerable when that
// is also the destination.
const canTargetSelectedFolder = selectedFolder != null && destinationKind === "current";
const destination = (): ImportDestination => {
if (destinationWorkspaceId == null) {
return { type: "new_workspace" };
}
return {
type: "existing_workspace",
workspaceId: destinationWorkspaceId,
folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined,
};
};
const handlePreview = async () => {
const handleImport = async () => {
setIsLoading(true);
try {
const nextPlan =
filePath != null
? await planFile(filePath, destination())
: await planUrl(trimmedSource, destination());
setPlan(nextPlan);
setItems(nextPlan.items);
} catch (err) {
onError(err);
if (filePath != null) {
await importFile(filePath);
} else {
await importUrl(trimmedSource);
}
} finally {
setIsLoading(false);
}
};
const handleCommit = async () => {
if (plan == null) return;
setIsLoading(true);
try {
await commit({ ...plan, items });
} catch (err) {
onError(err);
} finally {
setIsLoading(false);
}
};
const itemTree = useMemo(() => buildItemTree(items), [items]);
// A folder row's checkbox aggregates its subtree the way the git commit tree does: creates and
// updates toggle together, while removals only ever cascade beneath a removed folder. Checking
// anything also brings back the folders it needs to live in.
const toggleNode = (node: CheckboxTreeNode<ImportPlanItem>, checked: boolean) => {
const targets = new Set(
collectItems(node)
.filter((i) => togglesWith(node.data, i))
.map((i) => i.modelId),
);
if (checked) {
const byId = new Map(items.map((i) => [i.modelId, i]));
for (const ancestor of ancestorsOf(node.data, byId)) {
if (ancestor.action === "not_imported") targets.add(ancestor.modelId);
}
}
setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i)));
};
const resolveConflict = (modelId: string, resolution: "keep_mine" | "take_source") => {
setItems((prev) =>
prev.map((item) => (item.modelId === modelId ? { ...item, resolution } : item)),
);
};
// A row the user can't meaningfully toggle on its own: a planned resource inside a deselected
// new folder can't exist, and a removed folder takes its contents with it.
const disabledIds = useMemo(() => {
const disabled = new Set<string>();
const byId = new Map(items.map((i) => [i.modelId, i]));
for (const item of items) {
for (const parent of ancestorsOf(item, byId)) {
if (parent.model !== "folder") break;
const missing =
(parent.action === "create" || parent.action === "not_imported") && !parent.selected;
// A not-imported row stays checkable: checking it brings its folders back with it
if (missing && item.action !== "delete" && item.action !== "not_imported") {
disabled.add(item.modelId);
}
if (parent.action === "delete" && parent.selected && item.action === "delete") {
disabled.add(item.modelId);
}
}
}
return disabled;
}, [items]);
if (plan != null) {
const unchanged = items.filter((i) => i.action === "unchanged");
const footerNote =
unchanged.length > 0
? `${unchanged.length} ${pluralize("resource", unchanged.length)} unchanged`
: "";
const changeCount = items.filter((item) => {
if (disabledIds.has(item.modelId)) return false;
if (item.action === "conflict") return item.resolution === "take_source";
if (item.action === "unchanged") return false;
return item.selected;
}).length;
const destinationLabel = (() => {
if (plan.destination.type === "new_workspace") return "New workspace";
const { workspaceId, folderId } = plan.destination;
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
return folderId != null && folderId === selectedFolder?.id
? `${name} / ${selectedFolder.name}`
: name;
})();
// The destination workspace roots the tree. It is not a plan item — commit always applies
// it — so its checkbox only aggregates the subtree.
const workspaceRoot: CheckboxTreeNode<ImportPlanItem> = (() => {
const planned = plan.resources.workspaces[0];
const planDestination = plan.destination;
const existing =
planDestination.type === "existing_workspace"
? workspaces.find((w) => w.id === planDestination.workspaceId)
: null;
return {
key: existing?.id ?? planned?.id ?? "workspace",
data: {
action: plan.destination.type === "new_workspace" ? "create" : "unchanged",
model: "workspace",
modelId: existing?.id ?? planned?.id ?? "workspace",
name: existing?.name ?? planned?.name ?? "New workspace",
selected: true,
changedFields: [],
},
children: itemTree,
};
})();
return (
<VStack space={4} className="pb-4">
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
<PreviewRow label="Detected format" value={plan.importer} />
<PreviewRow label="Destination" value={destinationLabel} />
</div>
<div className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]">
<CheckboxTree
node={workspaceRoot}
checked={nodeCheckedStatus}
onCheck={toggleNode}
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
isCollapsedByDefault={(n) => n.data.action === "not_imported"}
isRelevant={(n) => n.data.model === "workspace" || n.data.action !== "unchanged"}
renderRow={(n) => <ImportTreeRow item={n.data} onResolveConflict={resolveConflict} />}
/>
</div>
{plan.warnings.length > 0 && (
<div>
<div className="text-sm font-semibold mb-1">Import details</div>
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
{plan.warnings.map((warning) => (
<div
key={`${warning.title}:${warning.detail}`}
className="flex items-start gap-2.5 px-3 py-2.5"
>
<Icon icon="info" color="info" size="sm" className="mt-0.5" />
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
</div>
))}
</div>
</div>
)}
<HStack space={2} alignItems="center" className="mt-3">
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
<Button
className="ml-auto"
color="secondary"
variant="border"
disabled={isLoading}
onClick={() => {
setPlan(null);
setItems([]);
}}
>
Back
</Button>
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
{isLoading
? "Importing"
: changeCount > 0
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
: "Done"}
</Button>
</HStack>
</VStack>
);
}
const lastImported =
originSources.find((s) => s.workspaceId === destinationWorkspaceId) ??
linkedSources.reduce<ImportSource | null>(
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
null,
);
return (
<VStack ref={ref} space={4} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
@@ -433,9 +94,7 @@ function LoadedImportDataDialog({
className={classNames(
"w-full rounded-lg border border-dashed px-4 py-6",
"flex flex-col items-center gap-1 text-center",
isHovering
? "border-notice bg-surface-highlight"
: "border-border hover:border-text-subtle",
isHovering ? "border-notice bg-surface-highlight" : "border-border hover:border-text",
)}
>
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
@@ -456,285 +115,25 @@ function LoadedImportDataDialog({
</div>
</button>
<PlainInput
label="Or enter a file path or URL"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
/>
<VStack space={2}>
<Select
name="import-destination-kind"
label="Import location"
<PlainInput
label="Or enter a file path or URL"
size="sm"
value={destinationKind}
onChange={setDestinationChoice}
options={[
{ value: "new", label: "New Workspace" },
...(currentWorkspace != null
? [{ value: "current" as const, label: "Current Workspace" }]
: []),
{ value: "other", label: "Other Workspace" },
]}
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
/>
{destinationKind === "other" && (
<Select
name="import-destination-workspace"
label="Workspace"
hideLabel
size="sm"
value={otherWorkspaceId ?? ""}
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
filterable
options={[
{ value: "", label: "Select a workspace" },
...workspaces
.filter((w) => w.id !== currentWorkspace?.id)
.map((w) => ({ value: w.id, label: w.name })),
]}
/>
)}
{lastImported != null && destinationWorkspaceId != null ? (
<div className="text-xs text-text-subtle">
Last imported from {lastImported.originLabel} ·{" "}
{formatDistanceToNowStrict(`${lastImported.lastImportedAt}Z`, { addSuffix: true })}
</div>
) : linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId ? (
<div className="text-xs text-text-subtle">
This file was last imported into{" "}
<button
type="button"
className="underline hocus:text-text"
onClick={() => {
if (linkedWorkspace.id === currentWorkspace?.id) {
setDestinationChoice("current");
} else {
setDestinationChoice("other");
setOtherWorkspaceId(linkedWorkspace.id);
}
}}
>
{linkedWorkspace.name}
</button>
</div>
) : null}
{canTargetSelectedFolder && (
<Checkbox
checked={targetSelectedFolder}
title={`Place root resources in selected folder “${selectedFolder.name}`}
onChange={setTargetSelectedFolder}
/>
)}
</VStack>
<HStack space={2} justifyContent="end">
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
Cancel
</Button>
<Button
color="primary"
disabled={
trimmedSource === "" ||
isLoading ||
(destinationKind === "other" && otherWorkspaceId == null)
}
disabled={trimmedSource === "" || isLoading}
isLoading={isLoading}
onClick={handlePreview}
size="sm"
onClick={handleImport}
>
{isLoading ? "Analyzing" : "Preview Import"}
{isLoading ? "Importing" : "Import"}
</Button>
</HStack>
</VStack>
</VStack>
);
}
function ImportTreeRow({
item,
onResolveConflict,
}: {
item: ImportPlanItem;
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
}) {
return (
<>
{item.model === "workspace" || item.model === "folder" || item.model === "environment" ? (
<Icon
color="secondary"
icon={
item.model === "workspace" ? "house" : item.model === "folder" ? "folder" : "variable"
}
/>
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{item.name}</div>
{item.action === "conflict" ? (
<div className="shrink-0">
<SegmentedControl
name={`conflict-${item.modelId}`}
label={`Resolve conflict for ${item.name}`}
hideLabel
size="2xs"
help={actionHelp(item)}
value={item.resolution ?? "keep_mine"}
onChange={(v) => onResolveConflict(item.modelId, v)}
options={[
{ value: "keep_mine", label: "Keep mine" },
{ value: "take_source", label: "Take source" },
]}
/>
</div>
) : (
actionLabel(item) && (
<InlineCode
className={classNames(
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
"inline-flex items-center justify-center gap-1.5",
item.action === "create" && "text-success",
item.action === "update" && "text-info",
item.action === "delete" && "text-danger",
item.action === "keep_local" && item.selected && "text-warning",
item.action === "not_imported" && "text-text-subtlest",
)}
>
{actionLabel(item)}
<IconTooltip content={actionHelp(item)} iconSize="xs" />
</InlineCode>
)
)}
</>
);
}
function actionLabel(item: ImportPlanItem): string | null {
switch (item.action) {
case "create":
return "new";
case "update":
return "updated";
case "delete":
return "removed";
case "keep_local":
return "edited";
case "not_imported":
return "not imported";
default:
return null;
}
}
function actionHelp(item: ImportPlanItem): string | null {
const help = (text: string) =>
item.changedFields.length > 0
? `${text} · ${item.changedFields.map(fieldLabel).join(", ")}`
: text;
switch (item.action) {
case "create":
return "Added since the last import";
case "update":
return help("Changed since the last import");
case "delete":
return item.reason === "moved_into_not_imported_folder"
? "Moved into a folder that isn't imported. Import that folder instead to follow the move"
: "Deleted since the last import";
case "keep_local":
return help("Local edits made since the last import. Importing will revert them if checked");
case "conflict":
return help("Changed both here and in the file since the last import");
case "not_imported":
return "In the file, but not imported. Check it to import it";
default:
return null;
}
}
function fieldLabel(field: string): string {
return field.replace(/([A-Z])/g, " $1").toLowerCase();
}
/** Every plan item above `item`, nearest first. */
function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): ImportPlanItem[] {
const ancestors: ImportPlanItem[] = [];
const seen = new Set<string>();
let parentId = item.parentId;
while (parentId != null && !seen.has(parentId)) {
seen.add(parentId);
const parent = byId.get(parentId);
if (parent == null) break;
ancestors.push(parent);
parentId = parent.parentId;
}
return ancestors;
}
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem>[] {
const byId = new Map(items.map((i) => [i.modelId, i]));
const childrenOf = new Map<string, ImportPlanItem[]>();
const roots: ImportPlanItem[] = [];
for (const item of items) {
if (item.parentId != null && byId.has(item.parentId)) {
const siblings = childrenOf.get(item.parentId) ?? [];
siblings.push(item);
childrenOf.set(item.parentId, siblings);
} else {
roots.push(item);
}
}
const foldersFirst = (list: ImportPlanItem[]) => [
...list.filter((i) => i.model === "folder"),
...list.filter((i) => i.model !== "folder"),
];
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<ImportPlanItem> => ({
key: item.modelId,
data: item,
children: seen.has(item.modelId)
? []
: foldersFirst(childrenOf.get(item.modelId) ?? []).map((c) =>
toNode(c, new Set([...seen, item.modelId])),
),
});
return foldersFirst(roots).map((r) => toNode(r, new Set()));
}
function collectItems(node: CheckboxTreeNode<ImportPlanItem>): ImportPlanItem[] {
return [node.data, ...node.children.flatMap(collectItems)];
}
/**
* Whether toggling `root`'s checkbox also toggles `item` in its subtree. Destructive decisions
* (deletions, reverting local edits) never ride along with a parent toggle.
*/
function togglesWith(root: ImportPlanItem, item: ImportPlanItem): boolean {
if (item.model === "workspace") return false;
if (root.action === "delete") return item.action === "delete";
if (item.action === "keep_local") {
return root.modelId === item.modelId && item.model !== "folder";
}
return item.action === "create" || item.action === "update" || item.action === "not_imported";
}
function nodeCheckedStatus(
node: CheckboxTreeNode<ImportPlanItem>,
): boolean | "indeterminate" | "hidden" {
const covered = collectItems(node).filter((i) => togglesWith(node.data, i));
if (covered.length === 0) return "hidden";
const selected = covered.filter((i) => i.selected).length;
if (selected === covered.length) return true;
if (selected === 0) return false;
return "indeterminate";
}
function PreviewRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-4 px-3 py-2 text-sm">
<span className="text-text-subtle">{label}</span>
<span className="text-right font-medium">{value}</span>
</div>
);
}
@@ -2,9 +2,7 @@ import type {
Folder,
GrpcRequest,
HttpRequest,
HttpVersion,
InheritedBoolSetting,
InheritedHttpVersionSetting,
InheritedIntSetting,
WebsocketRequest,
Workspace,
@@ -15,7 +13,6 @@ import {
modelSupportsSetting,
type RequestSettingDefinition,
SETTING_FOLLOW_REDIRECTS,
SETTING_HTTP_VERSION,
SETTING_REQUEST_MESSAGE_SIZE,
SETTING_REQUEST_TIMEOUT,
SETTING_SEND_COOKIES,
@@ -24,7 +21,6 @@ import {
} from "../lib/requestSettings";
import { Checkbox } from "./core/Checkbox";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import {
SettingOverrideRow,
SettingRow,
@@ -42,21 +38,37 @@ 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 = {
@@ -66,7 +78,10 @@ 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);
@@ -139,26 +154,12 @@ export function ModelSettingsEditor({ model, showSectionTitles = false }: Props)
}
/>
)}
{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}
@@ -194,7 +195,7 @@ export function ModelSettingsEditor({ model, showSectionTitles = false }: Props)
}
export function countOverriddenSettings(model: ModelWithSettings) {
const settings: (BooleanSetting | IntegerSetting | HttpVersionSetting)[] = [];
const settings: (BooleanSetting | IntegerSetting)[] = [];
if (modelSupportsCookieSettings(model)) {
settings.push(model.settingSendCookies, model.settingStoreCookies);
@@ -203,22 +204,22 @@ export function countOverriddenSettings(model: ModelWithSettings) {
settings.push(model.settingValidateCertificates);
if (modelSupportsHttpSettings(model)) {
settings.push(
model.settingFollowRedirects,
model.settingRequestTimeout,
model.settingHttpVersion,
);
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
}
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>);
@@ -231,7 +232,10 @@ function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<Cook
}
}
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>);
@@ -242,7 +246,10 @@ function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSett
}
}
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>);
@@ -273,15 +280,21 @@ 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);
}
@@ -304,7 +317,11 @@ function BooleanSettingRow({
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
if (!inherited) {
return (
@@ -335,63 +352,6 @@ 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,
@@ -405,11 +365,18 @@ 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}
@@ -462,13 +429,20 @@ 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}
@@ -593,18 +567,13 @@ 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 | HttpVersionSetting,
fallback: BooleanSetting | IntegerSetting,
) {
for (const ancestor of ancestors) {
const setting = ancestor[key] as BooleanSetting | IntegerSetting | HttpVersionSetting;
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
if (isInheritedSetting(setting)) {
if (setting.enabled === true) {
return setting.value;
@@ -620,7 +589,6 @@ function resolveInheritedValue(
type WorkspaceSettings = Pick<
Workspace,
| "settingFollowRedirects"
| "settingHttpVersion"
| "settingRequestMessageSize"
| "settingRequestTimeout"
| "settingSendCookies"
@@ -630,12 +598,14 @@ type WorkspaceSettings = Pick<
type BooleanWorkspaceSettingKey = Exclude<
keyof WorkspaceSettings,
"settingRequestTimeout" | "settingRequestMessageSize" | "settingHttpVersion"
"settingRequestTimeout" | "settingRequestMessageSize"
>;
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) {
@@ -656,5 +626,9 @@ 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
);
}
@@ -1,114 +0,0 @@
import { Icon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ReactNode } from "react";
import { useState } from "react";
import type { CheckboxProps } from "./Checkbox";
import { Checkbox } from "./Checkbox";
export interface CheckboxTreeNode<T> {
key: string;
data: T;
children: CheckboxTreeNode<T>[];
}
interface Props<T> {
node: CheckboxTreeNode<T>;
depth?: number;
/** Return "hidden" to render row alignment space instead of a checkbox */
checked: (node: CheckboxTreeNode<T>) => CheckboxProps["checked"] | "hidden";
onCheck: (node: CheckboxTreeNode<T>, checked: boolean) => void;
checkboxTitle?: (node: CheckboxTreeNode<T>) => string;
isCheckboxDisabled?: (node: CheckboxTreeNode<T>) => boolean;
/** An irrelevant row is hidden unless one of its descendants is relevant */
isRelevant: (node: CheckboxTreeNode<T>) => boolean;
/** A node that starts collapsed, so a large subtree doesn't crowd out the rest */
isCollapsedByDefault?: (node: CheckboxTreeNode<T>) => boolean;
renderRow: (node: CheckboxTreeNode<T>) => ReactNode;
onSelectRow?: (node: CheckboxTreeNode<T>) => void;
canSelectRow?: (node: CheckboxTreeNode<T>) => boolean;
isRowSelected?: (node: CheckboxTreeNode<T>) => boolean;
}
export function CheckboxTree<T>(props: Props<T>) {
const { node, depth = 0 } = props;
const [collapsed, setCollapsed] = useState<boolean>(
() => props.isCollapsedByDefault?.(node) ?? false,
);
if (!hasRelevantNode(node, props.isRelevant)) return null;
const checked = props.checked(node);
const selected = props.isRowSelected?.(node) ?? false;
const selectable = props.onSelectRow != null && (props.canSelectRow?.(node) ?? true);
const hasVisibleChildren = node.children.some((c) => hasRelevantNode(c, props.isRelevant));
const rowContent = (
<div className="flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left">
{props.renderRow(node)}
</div>
);
return (
<div
className={classNames(
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
)}
>
<div
className={classNames(
"relative flex gap-1 w-full h-xs items-center",
selected ? "text-text" : "text-text-subtle",
)}
>
{selected && (
<div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
)}
{hasVisibleChildren ? (
<button
type="button"
aria-label={collapsed ? "Expand" : "Collapse"}
aria-expanded={!collapsed}
className="shrink-0 text-text-subtlest hocus:text-text"
onClick={() => setCollapsed((v) => !v)}
>
<Icon size="sm" icon={collapsed ? "chevron_right" : "chevron_down"} />
</button>
) : (
<span aria-hidden className="w-4 shrink-0" />
)}
{checked === "hidden" ? (
<span aria-hidden className="w-4 mr-0.5 shrink-0" />
) : (
<Checkbox
checked={checked}
title={props.checkboxTitle?.(node) ?? "Toggle"}
hideLabel
disabled={props.isCheckboxDisabled?.(node)}
onChange={(checked) => props.onCheck(node, checked)}
/>
)}
{selectable ? (
<button
type="button"
className="flex-1 min-w-0 flex text-left"
onClick={() => props.onSelectRow?.(node)}
>
{rowContent}
</button>
) : (
rowContent
)}
</div>
{!collapsed &&
node.children.map((child) => (
<CheckboxTree key={child.key} {...props} node={child} depth={depth + 1} />
))}
</div>
);
}
function hasRelevantNode<T>(
node: CheckboxTreeNode<T>,
isRelevant: (node: CheckboxTreeNode<T>) => boolean,
): boolean {
return isRelevant(node) || node.children.some((c) => hasRelevantNode(c, isRelevant));
}
+5 -7
View File
@@ -10,13 +10,11 @@ export interface DialogProps {
children: ReactNode;
open: boolean;
onClose?: () => void;
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
disableClose?: boolean;
disableBackdropClose?: boolean;
title?: ReactNode;
description?: ReactNode;
className?: string;
size?: DialogSize;
/** Hide the built-in close button without changing backdrop or Escape behavior. */
hideX?: boolean;
noPadding?: boolean;
noScroll?: boolean;
@@ -29,7 +27,7 @@ export function Dialog({
size = "full",
open,
onClose,
disableClose,
disableBackdropClose,
title,
description,
hideX,
@@ -44,7 +42,7 @@ export function Dialog({
);
return (
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog">
<div
role="dialog"
className={classNames(
@@ -60,7 +58,7 @@ export function Dialog({
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
// dialogs and can be intercepted by children if needed.
if (e.key === "Escape") {
if (!disableClose) onClose?.();
onClose?.();
e.stopPropagation();
e.preventDefault();
}
@@ -112,7 +110,7 @@ export function Dialog({
</div>
{/*Put close at the end so that it's the last thing to be tabbed to*/}
{!disableClose && !hideX && (
{!hideX && (
<div className="ml-auto absolute right-1 top-1">
<IconButton
className="opacity-70 hover:opacity-100"
@@ -601,8 +601,6 @@ function getExtensions({
EditorView.contentAttributes.of({
autocapitalize: "off",
autocorrect: "off",
// Keeps macOS Writing Tools from offering to write code for us
writingsuggestions: "false",
}),
EditorView.domEventHandlers({
focus: () => {
@@ -1,202 +0,0 @@
import { SearchQuery } from "@codemirror/search";
import { EditorState } from "@codemirror/state";
import { describe, expect, test } from "vite-plus/test";
import {
currentMatch,
literalSearch,
MAX_COUNT,
MatchCounter,
normalizeDoc,
normalizeSearch,
scanNormalized,
scanQuery,
} from "./searchMatchCount";
type QueryConfig = ConstructorParameters<typeof SearchQuery>[0];
const stateOf = (doc: string) => EditorState.create({ doc });
/**
* The matches the counter finds, having checked them against the search panel's own cursor.
*
* The cursor decides which ranges the editor highlights and which one `find next` lands on, so
* a count that doesn't agree with it is a wrong count, however fast it was to produce.
*/
function matchesOf(doc: string, config: QueryConfig) {
const state = stateOf(doc);
const query = new SearchQuery(config);
const matches = new MatchCounter().matches(state, query);
expect(matches).toEqual(scanQuery(state, query));
return matches;
}
const countOf = (doc: string, config: QueryConfig) => matchesOf(doc, config).length;
describe("counting", () => {
test("counts every match, whatever the case", () => {
expect(countOf("one Two three two", { search: "two" })).toBe(2);
expect(countOf("one Two three two", { search: "two", caseSensitive: true })).toBe(1);
});
test("skips matches overlapping an earlier one", () => {
expect(countOf("aaaaa", { search: "aa" })).toBe(2);
expect(countOf("ababa", { search: "aba" })).toBe(1);
});
test("treats a query as text, not as a pattern", () => {
expect(countOf("a.b axb", { search: "a.b" })).toBe(1);
});
test("unquotes escapes unless the query is literal", () => {
expect(countOf("one\ntwo\nthree", { search: "\\n" })).toBe(2);
expect(countOf("one\\ntwo", { search: "\\n", literal: true })).toBe(1);
});
test("counts regexp and whole word queries through the cursor", () => {
expect(literalSearch(new SearchQuery({ search: "a", regexp: true }))).toBe(null);
expect(literalSearch(new SearchQuery({ search: "a", wholeWord: true }))).toBe(null);
expect(countOf("a1 b2 c3", { search: "[a-z]\\d", regexp: true })).toBe(3);
expect(countOf("cat cats cat", { search: "cat", wholeWord: true })).toBe(2);
});
test("stops counting at the cap", () => {
expect(countOf("x".repeat(MAX_COUNT + 100), { search: "x" })).toBe(MAX_COUNT + 1);
});
test("reports where the matches are", () => {
expect(matchesOf("ab..ab", { search: "ab" })).toEqual([
{ from: 0, to: 2 },
{ from: 4, to: 6 },
]);
});
test("finds nothing to match with an empty needle", () => {
expect(scanNormalized(normalizeDoc("abc", false), "")).toEqual([]);
});
});
describe("normalization", () => {
test("finds what a character decomposes into", () => {
// The é is one character holding an `e`, and the match covers the whole of it
expect(matchesOf("café", { search: "e" })).toEqual([{ from: 3, to: 4 }]);
expect(matchesOf("file", { search: "fi" })).toEqual([{ from: 0, to: 1 }]);
expect(matchesOf("a…b", { search: "..." })).toEqual([{ from: 1, to: 2 }]);
expect(countOf("one two", { search: "one two" })).toBe(1);
expect(countOf("full width", { search: "full" })).toBe(1);
});
test("matches a decomposed query against composed text, and the reverse", () => {
expect(countOf("café", { search: "café" })).toBe(1);
expect(countOf("café", { search: "café" })).toBe(1);
expect(countOf("café", { search: "café" })).toBe(1);
});
test("keeps offsets straight after an expansion", () => {
expect(matchesOf("é.é.end", { search: "end" })).toEqual([{ from: 4, to: 7 }]);
expect(matchesOf("fififi stop", { search: "stop" })).toEqual([{ from: 4, to: 8 }]);
});
test("normalizes the query whole, the document by character", () => {
expect(normalizeSearch("CAFÉ", false)).toBe("café");
expect(normalizeSearch("CAFÉ", true)).toBe("CAFÉ");
// Whole-string NFKD would fold this to a final sigma, which the cursor never does
expect(normalizeDoc("ΟΔΟΣ", false).text).toBe("οδοσ");
});
test("leaves a document that normalizes to itself untouched", () => {
const { text, expansions } = normalizeDoc("plain 日本 🎉 text", false);
expect(text).toBe("plain 日本 🎉 text");
expect(expansions).toEqual([]);
});
});
describe("current match", () => {
const matches = [
{ from: 0, to: 2 },
{ from: 4, to: 6 },
{ from: 8, to: 10 },
];
test("counts from one, and reports 0 off a match", () => {
expect(currentMatch(matches, { from: 4, to: 6 })).toBe(2);
expect(currentMatch(matches, { from: 8, to: 10 })).toBe(3);
expect(currentMatch(matches, { from: 5, to: 5 })).toBe(2);
expect(currentMatch(matches, { from: 2, to: 3 })).toBe(0);
expect(currentMatch(matches, { from: 4, to: 7 })).toBe(0);
expect(currentMatch([], { from: 0, to: 0 })).toBe(0);
});
test("moving the selection doesn't scan again", () => {
const state = stateOf("a1 b2 c3");
const query = new SearchQuery({ search: "\\d", regexp: true });
const counter = new MatchCounter();
const found = counter.matches(state, query);
// The document a selection-only transaction leaves behind is the one already scanned
const moved = state.update({ selection: { anchor: 4, head: 5 } }).state;
expect(counter.matches(moved, query)).toBe(found);
expect(currentMatch(found, moved.selection.main)).toBe(2);
});
});
/**
* The mapping from normalized offsets back to document offsets is the part of this that can go
* quietly wrong, and only on input nobody thinks to write a case for. So generate the input.
*/
describe("against the cursor, on awkward text", () => {
const ALPHABET = [
..."abcABC .\\\n".split(""),
"é",
"é",
"fi",
"…",
" ",
"İ",
"Σ",
"ς",
"日",
"🎉",
"Ⅻ",
"",
"①",
"́",
];
/** Seeded, so a failure is the same failure next run */
function random(seed: number) {
let state = seed;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 2 ** 32;
};
}
for (const caseSensitive of [false, true]) {
test(`agrees on every generated document (caseSensitive: ${caseSensitive})`, () => {
const next = random(caseSensitive ? 20260831 : 7);
for (let round = 0; round < 400; round++) {
const doc = Array.from(
{ length: 2 + Math.floor(next() * 60) },
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
).join("");
// Half the queries are lifted out of the document, so matches are actually found
const start = Math.floor(next() * doc.length);
const search =
next() < 0.5
? doc.slice(start, start + 1 + Math.floor(next() * 3))
: Array.from(
{ length: 1 + Math.floor(next() * 2) },
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
).join("");
if (search === "") continue;
const state = stateOf(doc);
const query = new SearchQuery({ search, caseSensitive });
const where = `doc=${JSON.stringify(doc)} search=${JSON.stringify(search)}`;
expect(new MatchCounter().matches(state, query), where).toEqual(scanQuery(state, query));
}
});
}
});
@@ -1,232 +1,7 @@
import { getSearchQuery, type SearchQuery, searchPanelOpen } from "@codemirror/search";
import type { EditorState, Extension, Text } from "@codemirror/state";
import { getSearchQuery, searchPanelOpen } from "@codemirror/search";
import type { Extension } from "@codemirror/state";
import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
/** Matches are counted no further than this, since an exact total stops being useful long before */
export const MAX_COUNT = 9999;
/** What normalizing rewrites: anything outside ASCII, plus the case it folds */
const REWRITTEN = /\P{ASCII}|[A-Z]+/gu;
const REWRITTEN_CASE_SENSITIVE = /\P{ASCII}/gu;
export interface Match {
from: number;
to: number;
}
/** A character whose normalized form is a different length, shifting every offset past it */
interface Expansion {
normFrom: number;
normTo: number;
docFrom: number;
docTo: number;
}
/** A document as SearchCursor compares it, with what's needed to get back to real offsets */
export interface NormalizedDoc {
text: string;
expansions: Expansion[];
}
/**
* Rewrites a document the way SearchCursor does — NFKD, then a case fold unless the search is
* case-sensitive — in a single pass rather than one call per code point.
*
* The cursor spends 90% of its time asking ICU about one character at a time, which is what
* makes counting matches in a large response slow. Doing it a character at a time still matters
* for the result, since it keeps NFKD from reordering marks across characters, so the
* granularity stays and only the repeated work goes: each distinct character is normalized once
* and the answer reused, and ASCII runs never reach ICU at all.
*/
export function normalizeDoc(text: string, caseSensitive: boolean): NormalizedDoc {
const rewritten = new Map<string, string>();
const expansions: Expansion[] = [];
let shift = 0;
const normalized = text.replace(
caseSensitive ? REWRITTEN_CASE_SENSITIVE : REWRITTEN,
(chunk: string, at: number) => {
// An ASCII run only ever folds case, which can't change its length
if (chunk.charCodeAt(0) < 0x80) return chunk.toLowerCase();
let out = rewritten.get(chunk);
if (out === undefined) {
out = chunk.normalize("NFKD");
if (!caseSensitive) out = out.toLowerCase();
rewritten.set(chunk, out);
}
if (out.length !== chunk.length) {
expansions.push({
normFrom: at + shift,
normTo: at + shift + out.length,
docFrom: at,
docTo: at + chunk.length,
});
shift += out.length - chunk.length;
}
return out;
},
);
return { text: normalized, expansions };
}
/** The query as SearchCursor compares it, which it normalizes whole rather than by character */
export function normalizeSearch(search: string, caseSensitive: boolean): string {
const normalized = search.normalize("NFKD");
return caseSensitive ? normalized : normalized.toLowerCase();
}
/** Every occurrence of `needle`, skipping matches that overlap an earlier one */
export function scanNormalized(doc: NormalizedDoc, needle: string): Match[] {
const matches: Match[] = [];
if (needle === "") return matches;
const { text } = doc;
let pos = text.indexOf(needle);
while (pos >= 0) {
let end = pos + needle.length;
// However the query was cut, a match ends on a whole code point, as the cursor's do
if (isLowSurrogate(text.charCodeAt(end))) end++;
matches.push({ from: docStart(doc, pos), to: docEnd(doc, end) });
if (matches.length > MAX_COUNT) break;
pos = text.indexOf(needle, resumeAfter(doc, end));
}
return matches;
}
/** The same through the query's own cursor, which handles regexps and whole words */
export function scanQuery(state: EditorState, query: SearchQuery): Match[] {
const matches: Match[] = [];
const cursor = query.getCursor(state);
for (let result = cursor.next(); !result.done; result = cursor.next()) {
matches.push({ from: result.value.from, to: result.value.to });
if (matches.length > MAX_COUNT) break;
}
return matches;
}
const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff;
/** The last character expansion beginning at or before `offset`, if there is one */
function expansionAt({ expansions }: NormalizedDoc, offset: number): Expansion | null {
let low = 0;
let high = expansions.length - 1;
let found: Expansion | null = null;
while (low <= high) {
const mid = (low + high) >> 1;
if (expansions[mid]!.normFrom <= offset) {
found = expansions[mid]!;
low = mid + 1;
} else {
high = mid - 1;
}
}
return found;
}
function docStart(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
if (expansion == null) return offset;
// A match starting inside a character's expansion starts at the character
return offset < expansion.normTo
? expansion.docFrom
: offset - (expansion.normTo - expansion.docTo);
}
/**
* Where scanning picks up after a match ending at `offset`.
*
* The cursor moves through the document a character at a time, so once a match ends inside a
* character's expansion the rest of that expansion is behind it — "…" holds three dots but only
* ever counts as one match of ".".
*/
function resumeAfter(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
return expansion != null && offset > expansion.normFrom && offset < expansion.normTo
? expansion.normTo
: offset;
}
function docEnd(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
if (expansion == null) return offset;
if (offset <= expansion.normFrom) return expansion.docFrom;
// A match ending inside a character's expansion covers the whole character
return offset < expansion.normTo
? expansion.docTo
: offset - (expansion.normTo - expansion.docTo);
}
/** Position of the match holding the selection, counting from one, or 0 when it isn't on one */
export function currentMatch(matches: Match[], selection: { from: number; to: number }): number {
let index = 0;
for (const match of matches) {
index++;
if (match.from <= selection.from && match.to >= selection.to) return index;
}
return 0;
}
/** The text a plain query looks for, or null when only the cursor can answer it */
export function literalSearch(query: SearchQuery): string | null {
if (query.regexp || query.wholeWord || query.test != null) return null;
// Mirrors SearchQuery's own unquoting, which the published type doesn't expose
return query.literal
? query.search
: query.search.replace(/\\([nrt\\])/g, (_, ch) =>
ch === "n" ? "\n" : ch === "r" ? "\r" : ch === "t" ? "\t" : "\\",
);
}
/**
* Finds the matches for the search panel, keeping the normalized document and the matches it
* last found, so neither moving the selection nor typing another character starts over.
*/
export class MatchCounter {
private doc: { doc: Text; caseSensitive: boolean; normalized: NormalizedDoc } | null = null;
private last: { doc: Text; query: SearchQuery; matches: Match[] } | null = null;
matches(state: EditorState, query: SearchQuery): Match[] {
const last = this.last;
if (last != null && last.doc === state.doc && last.query.eq(query)) {
return last.matches;
}
const matches = this.scan(state, query);
this.last = { doc: state.doc, query, matches };
return matches;
}
private scan(state: EditorState, query: SearchQuery): Match[] {
const search = literalSearch(query);
if (search == null) return scanQuery(state, query);
const doc = this.normalizedDoc(state.doc, query.caseSensitive);
return scanNormalized(doc, normalizeSearch(search, query.caseSensitive));
}
private normalizedDoc(doc: Text, caseSensitive: boolean): NormalizedDoc {
const cached = this.doc;
if (cached != null && cached.doc === doc && cached.caseSensitive === caseSensitive) {
return cached.normalized;
}
const normalized = normalizeDoc(doc.toString(), caseSensitive);
this.doc = { doc, caseSensitive, normalized };
return normalized;
}
}
/**
* A CodeMirror extension that displays the total number of search matches
* inside the built-in search panel.
@@ -235,7 +10,6 @@ export function searchMatchCount(): Extension {
return ViewPlugin.fromClass(
class {
private countEl: HTMLElement | null = null;
private counter = new MatchCounter();
constructor(private view: EditorView) {
this.updateCount();
@@ -264,21 +38,38 @@ export function searchMatchCount(): Extension {
}
this.ensureCountEl();
if (this.countEl == null) return;
if (!query.search) {
this.countEl.textContent = "0/0";
if (this.countEl) {
this.countEl.textContent = "0/0";
}
return;
}
const matches = this.counter.matches(state, query);
if (matches.length > MAX_COUNT) {
this.countEl.textContent = `${MAX_COUNT}+`;
} else if (matches.length === 0) {
this.countEl.textContent = "0/0";
} else {
const current = currentMatch(matches, state.selection.main);
this.countEl.textContent = `${current}/${matches.length}`;
const selection = state.selection.main;
let count = 0;
let currentIndex = 0;
const MAX_COUNT = 9999;
const cursor = query.getCursor(state);
for (let result = cursor.next(); !result.done; result = cursor.next()) {
count++;
const match = result.value;
if (match.from <= selection.from && match.to >= selection.to) {
currentIndex = count;
}
if (count > MAX_COUNT) break;
}
if (this.countEl) {
if (count > MAX_COUNT) {
this.countEl.textContent = `${MAX_COUNT}+`;
} else if (count === 0) {
this.countEl.textContent = "0/0";
} else if (currentIndex > 0) {
this.countEl.textContent = `${currentIndex}/${count}`;
} else {
this.countEl.textContent = `0/${count}`;
}
}
}
@@ -113,15 +113,7 @@ function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): P
/** Max number of pairs to show before prompting the user to reveal the rest */
const MAX_INITIAL_PAIRS = 30;
// Keyed on `stateKey` so no state survives a change of owner. Row ids alone can't tell owners
// apart — two pair sets can share ids (eg. one duplicated from the other), and the same-rows
// fast path below would swap in the new data without rebuilding the row editors, leaving any
// still-focused input showing the old owner's text.
export function PairEditor(props: PairEditorProps) {
return <PairEditorInner key={props.stateKey} {...props} />;
}
function PairEditorInner({
export function PairEditor({
allowFileValues,
allowMultilineValues,
className,
@@ -6,7 +6,6 @@ import { useStateWithDeps } from "../../hooks/useStateWithDeps";
import { generateId } from "../../lib/generateId";
import { Button } from "./Button";
import { IconButton, type IconButtonProps } from "./IconButton";
import { IconTooltip } from "./IconTooltip";
import { Label } from "./Label";
interface Props<T extends string> {
@@ -37,15 +36,11 @@ export function SegmentedControl<T extends string>({
const containerRef = useRef<HTMLDivElement>(null);
const id = useRef(`input-${generateId()}`);
// A visually hidden label has nowhere to show the help, so the last option carries it
const inlineHelp =
hideLabel && help ? <IconTooltip tabIndex={-1} content={help} iconSize="xs" /> : null;
return (
<div className="w-full grid">
<Label
htmlFor={id.current}
help={hideLabel ? undefined : help}
help={help}
visuallyHidden={hideLabel}
className={classNames(labelClassName)}
>
@@ -83,10 +78,9 @@ export function SegmentedControl<T extends string>({
}
}}
>
{options.map((o, i) => {
{options.map((o) => {
const isSelected = selectedValue === o.value;
const isActive = value === o.value;
const rightSlot = i === options.length - 1 ? inlineHelp : null;
if (o.icon == null) {
return (
<Button
@@ -101,7 +95,6 @@ export function SegmentedControl<T extends string>({
isActive && "text-text!",
"focus:ring-1 focus:ring-border-focus",
)}
rightSlot={rightSlot}
onClick={() => onChange(o.value)}
>
{o.label}
@@ -124,7 +117,6 @@ export function SegmentedControl<T extends string>({
)}
title={o.label}
icon={o.icon}
rightSlot={rightSlot}
onClick={() => onChange(o.value)}
/>
);
+2 -6
View File
@@ -119,13 +119,9 @@ export function Select<T extends string>({
)}
>
<Button
className={classNames(
"w-full text-sm font-mono",
disabled && "border-dotted",
isInvalidSelection && "border-danger",
)}
className="w-full text-sm font-mono"
justify="start"
variant="input"
variant="border"
size={size}
leftSlot={leftSlot}
disabled={disabled}
@@ -21,8 +21,6 @@ import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button";
import type { CheckboxProps } from "../core/Checkbox";
import { Checkbox } from "../core/Checkbox";
import type { CheckboxTreeNode } from "../core/CheckboxTree";
import { CheckboxTree } from "../core/CheckboxTree";
import { DiffViewer } from "../core/Editor/DiffViewer";
import { Input } from "../core/Input";
import { Separator } from "../core/Separator";
@@ -45,7 +43,10 @@ interface CommitTreeNode {
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
const callbacks = useGitCallbacks(syncDir);
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(syncDir, callbacks);
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(
syncDir,
callbacks,
);
const [isPushing, setIsPushing] = useState(false);
const [commitError, setCommitError] = useState<string | null>(null);
const [message, setMessage] = useState<string>("");
@@ -142,15 +143,6 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
return next(workspace, []);
}, [workspace, internalEntries]);
const treeNode: CheckboxTreeNode<CommitTreeNode> | null = useMemo(() => {
const toTreeNode = (n: CommitTreeNode): CheckboxTreeNode<CommitTreeNode> => ({
key: n.status.relaPath + n.status.status + n.status.staged,
data: n,
children: n.children.map(toTreeNode),
});
return tree == null ? null : toTreeNode(tree);
}, [tree]);
const checkNode = useCallback(
(treeNode: CommitTreeNode) => {
const checked = nodeCheckedStatus(treeNode);
@@ -198,7 +190,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
[restore],
);
if (tree == null || treeNode == null) {
if (tree == null) {
return null;
}
@@ -229,18 +221,12 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
style={innerStyle}
className="h-full overflow-y-auto pb-3 pr-0.5 transform-cpu"
>
<CheckboxTree
node={treeNode}
checked={(n) => nodeCheckedStatus(n.data)}
onCheck={(n) => checkNode(n.data)}
checkboxTitle={(n) =>
nodeCheckedStatus(n.data) ? "Unstage change" : "Stage change"
}
isRelevant={(n) => n.data.status.status !== "current"}
canSelectRow={(n) => n.data.status.status !== "current"}
onSelectRow={(n) => handleSelectChild(n.data.status)}
isRowSelected={(n) => selectedEntry?.relaPath === n.data.status.relaPath}
renderRow={(n) => <CommitTreeRow node={n.data} />}
<TreeNodeChildren
node={tree}
depth={0}
onCheck={checkNode}
onSelect={handleSelectChild}
selectedPath={selectedEntry?.relaPath ?? null}
/>
{externalEntries.find((e) => e.status !== "current") && (
<>
@@ -258,7 +244,10 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
</div>
)}
secondSlot={({ style: innerStyle }) => (
<div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
<div
style={innerStyle}
className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2"
>
<Input
className="text-base! font-sans rounded-md"
placeholder="Commit message..."
@@ -312,39 +301,96 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
);
}
function CommitTreeRow({ node }: { node: CommitTreeNode }) {
function TreeNodeChildren({
node,
depth,
onCheck,
onSelect,
selectedPath,
}: {
node: CommitTreeNode | null;
depth: number;
onCheck: (node: CommitTreeNode, checked: boolean) => void;
onSelect: (entry: GitStatusEntry) => void;
selectedPath: string | null;
}) {
if (node === null) return null;
if (!isNodeRelevant(node)) return null;
const checked = nodeCheckedStatus(node);
const isSelected = selectedPath === node.status.relaPath;
return (
<>
{node.model.model !== "http_request" &&
node.model.model !== "grpc_request" &&
node.model.model !== "websocket_request" ? (
<Icon
color="secondary"
icon={
node.model.model === "folder"
? "folder"
: node.model.model === "environment"
? "variable"
: "house"
}
<div
className={classNames(
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
)}
>
<div
className={classNames(
"relative flex gap-1 w-full h-xs items-center",
isSelected ? "text-text" : "text-text-subtle",
)}
>
{isSelected && (
<div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
)}
<Checkbox
checked={checked}
title={checked ? "Unstage change" : "Stage change"}
hideLabel
onChange={(checked) => onCheck(node, checked)}
/>
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
{node.status.status !== "current" && (
<InlineCode
className={classNames(
"py-0 bg-transparent w-24 text-center shrink-0",
node.status.status === "modified" && "text-info",
node.status.status === "untracked" && "text-success",
node.status.status === "removed" && "text-danger",
)}
<button
type="button"
className={classNames("flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left")}
onClick={() => node.status.status !== "current" && onSelect(node.status)}
>
{node.status.status}
</InlineCode>
)}
</>
{node.model.model !== "http_request" &&
node.model.model !== "grpc_request" &&
node.model.model !== "websocket_request" ? (
<Icon
color="secondary"
icon={
node.model.model === "folder"
? "folder"
: node.model.model === "environment"
? "variable"
: "house"
}
/>
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
{node.status.status !== "current" && (
<InlineCode
className={classNames(
"py-0 bg-transparent w-24 text-center shrink-0",
node.status.status === "modified" && "text-info",
node.status.status === "untracked" && "text-success",
node.status.status === "removed" && "text-danger",
)}
>
{node.status.status}
</InlineCode>
)}
</button>
</div>
{node.children.map((childNode) => {
return (
<TreeNodeChildren
key={childNode.status.relaPath + childNode.status.status + childNode.status.staged}
node={childNode}
depth={depth + 1}
onCheck={onCheck}
onSelect={onSelect}
selectedPath={selectedPath}
/>
);
})}
</div>
);
}
@@ -449,6 +495,15 @@ function setCheckedAndChildren(
if (toUnstage.length > 0) unstage({ relaPaths: toUnstage });
}
function isNodeRelevant(node: CommitTreeNode): boolean {
if (node.status.status !== "current") {
return true;
}
// Recursively check children
return node.children.some((c) => isNodeRelevant(c));
}
function DiffPanel({
entry,
onDiscardChanges,
@@ -471,11 +526,13 @@ function DiffPanel({
size="2xs"
variant="border"
onClick={() => onDiscardChanges(entry)}
>
Discard Changes
</Button>
>Discard Changes</Button>
</div>
<DiffViewer original={prevYaml ?? ""} modified={nextYaml ?? ""} className="flex-1 min-h-0" />
<DiffViewer
original={prevYaml ?? ""}
modified={nextYaml ?? ""}
className="flex-1 min-h-0"
/>
</div>
);
}
+3 -1
View File
@@ -86,8 +86,10 @@ export async function promptDivergedStrategy({
showDialog({
id: "git-diverged",
title: "Branches Diverged",
hideX: true,
size: "sm",
disableClose: true,
disableBackdropClose: true,
onClose: () => resolve("cancel"),
render: ({ hide }) =>
DivergedDialog({
remote,
+2 -1
View File
@@ -14,8 +14,9 @@ export function showAlert({ id, title, body, size = "sm" }: AlertArgs) {
showDialog({
id,
title,
hideX: true,
size,
disableClose: true,
disableBackdropClose: true, // Prevent accidental dismisses
render: ({ hide }) => Alert({ onHide: hide, body }),
});
}
+2 -1
View File
@@ -18,8 +18,9 @@ export async function showConfirm({
return new Promise((onResult: ConfirmProps["onResult"]) => {
showDialog({
...extraProps,
hideX: true,
size,
disableClose: true,
disableBackdropClose: true, // Prevent accidental dismisses
render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }),
});
});
+17 -49
View File
@@ -1,34 +1,14 @@
import {
type BatchUpsertResult,
type ImportDestination,
type ImportPlan,
type ImportSource,
workspacesAtom,
} from "@yaakapp-internal/models";
import type { BatchUpsertResult } from "@yaakapp-internal/models";
import { FormattedError, VStack } from "@yaakapp-internal/ui";
import { Button } from "../components/core/Button";
import { ImportDataDialog } from "../components/ImportDataDialog";
import { activeFolderAtom } from "../hooks/useActiveFolder";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { showAlert } from "./alert";
import { showDialog } from "./dialog";
import { jotaiStore } from "./jotai";
import { pluralizeCount } from "./pluralize";
import { router } from "./router";
import { rpc } from "./rpc";
// Stable identities so the dialog's effects don't re-run (and cancel in-flight
// fetches) every time the dialog container re-renders.
const planFile = (filePath: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_data", { filePath, destination });
const planUrl = (url: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_url", { url, destination });
const listSources = (workspaceId: string) =>
rpc<ImportSource[]>("cmd_list_import_sources", { workspaceId });
const findSourcesForOrigin = (args: { filePath?: string; url?: string }) =>
rpc<ImportSource[]>("cmd_import_sources_for_origin", args);
export const importData = createFastMutation({
mutationKey: ["import_data"],
onError: (err: string) => {
@@ -41,41 +21,29 @@ export const importData = createFastMutation({
},
mutationFn: async () => {
return new Promise<void>((resolve, reject) => {
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
const workspaces = jotaiStore.get(workspacesAtom);
const selectedFolder = jotaiStore.get(activeFolderAtom);
showDialog({
id: "import",
title: "Import Data",
size: "lg",
disableClose: true,
size: "sm",
render: ({ hide }) => {
const cancel = () => {
hide();
resolve();
};
const fail = (err: unknown) => {
hide();
reject(err);
};
const commit = async (plan: ImportPlan) => {
const imported = await rpc<BatchUpsertResult>("cmd_commit_import", { plan });
hide();
await finishImport(imported);
resolve();
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => {
try {
await finishImport(await runImport());
resolve();
} catch (err) {
reject(err);
} finally {
hide();
}
};
return (
<ImportDataDialog
currentWorkspace={currentWorkspace}
workspaces={workspaces}
selectedFolder={selectedFolder}
planFile={planFile}
planUrl={planUrl}
listSources={listSources}
findSourcesForOrigin={findSourcesForOrigin}
commit={commit}
cancel={cancel}
onError={fail}
importFile={(filePath) =>
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath }))
}
importUrl={(url) =>
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url }))
}
/>
);
},
+14 -60
View File
@@ -12,7 +12,7 @@ import type {
UpdateResponse,
YaakNotification,
} from "@yaakapp-internal/tauri-client";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { HStack, Icon, VStack } from "@yaakapp-internal/ui";
import { openSettings } from "../commands/openSettings";
import { Button } from "../components/core/Button";
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
@@ -180,65 +180,9 @@ function showUpdateInstalledToast(version: string) {
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
const UPDATE_TOAST_ID = "update-info";
const { version, replyEventId, downloaded, install } = updateInfo;
const { version, replyEventId, downloaded } = updateInfo;
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&apos;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;
}
jotaiStore.set(updateAvailableAtom, { version, downloaded });
// Acknowledge the event, so we don't time out and try the fallback update logic
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
@@ -271,7 +215,17 @@ async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
>
{downloaded ? "Install Now" : "Download and Install"}
</ButtonInfiniteLoading>
{whatsNewButton}
<Button
size="xs"
color="info"
variant="border"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
await platform.openUrl(`https://yaak.app/changelog/${version}`);
}}
>
What&apos;s New
</Button>
</HStack>
),
});
@@ -1,251 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import type { JsonSchema } from "./jsonSchemaExample";
import { buildExampleFromSchema } from "./jsonSchemaExample";
describe("buildExampleFromSchema", () => {
test("fills scalar fields with placeholders", () => {
const schema: JsonSchema = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "number", format: "int32" },
active: { type: "boolean" },
data: { type: "string", format: "byte" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
name: "",
age: 0,
active: false,
data: "",
});
});
test("encodes 64-bit integers as strings", () => {
const schema: JsonSchema = {
type: "object",
properties: {
id: { type: "string", format: "int64" },
count: { type: "string", format: "uint64" },
offset: { type: "string", format: "sfixed64" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ id: "0", count: "0", offset: "0" });
});
test("fills date-time with a parseable timestamp", () => {
const schema: JsonSchema = {
type: "object",
properties: { createdAt: { type: "string", format: "date-time" } },
};
const example = buildExampleFromSchema(schema) as { createdAt: string };
expect(Number.isNaN(Date.parse(example.createdAt))).toBe(false);
});
test("fills a duration with a value that parses", () => {
const schema: JsonSchema = {
type: "object",
properties: { timeout: { type: "string", format: "duration" } },
};
// An empty string fails protobuf's Duration parsing, so the message wouldn't send
expect(buildExampleFromSchema(schema)).toEqual({ timeout: "0s" });
});
test("expands nested messages through $defs", () => {
const schema: JsonSchema = {
type: "object",
properties: { user: { $ref: "#/$defs/example.User" } },
$defs: {
"example.User": {
type: "object",
properties: {
name: { type: "string" },
address: { $ref: "#/$defs/example.Address" },
},
},
"example.Address": {
type: "object",
properties: { city: { type: "string" } },
},
},
};
expect(buildExampleFromSchema(schema)).toEqual({
user: { name: "", address: { city: "" } },
});
});
test("gives repeated fields a single placeholder item", () => {
const schema: JsonSchema = {
type: "object",
properties: {
tags: { type: "array", items: { type: "string" } },
users: { type: "array", items: { $ref: "#/$defs/example.User" } },
unknown: { type: "array" },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
tags: [""],
users: [{ name: "" }],
unknown: [],
});
});
test("uses the first value of an enum", () => {
const schema: JsonSchema = {
type: "object",
properties: {
status: { type: "string", enum: ["STATUS_UNSPECIFIED", "STATUS_ACTIVE"] },
empty: { type: "string", enum: [] },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ status: "STATUS_UNSPECIFIED", empty: "" });
});
test("gives maps a single placeholder entry", () => {
const schema: JsonSchema = {
type: "object",
properties: {
labels: { type: "object", additionalProperties: { type: "string" } },
users: { type: "object", additionalProperties: { $ref: "#/$defs/example.User" } },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
labels: { key: "" },
users: { key: { name: "" } },
});
});
test("stops at the root self-reference", () => {
const schema: JsonSchema = {
type: "object",
properties: {
value: { type: "string" },
children: { type: "array", items: { $ref: "#" } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ value: "", children: [{}] });
});
test("stops at a cycle between messages", () => {
const schema: JsonSchema = {
type: "object",
properties: { node: { $ref: "#/$defs/example.Node" } },
$defs: {
"example.Node": {
type: "object",
properties: {
name: { type: "string" },
parent: { $ref: "#/$defs/example.Node" },
leaf: { $ref: "#/$defs/example.Leaf" },
},
},
"example.Leaf": {
type: "object",
properties: { node: { $ref: "#/$defs/example.Node" } },
},
},
};
expect(buildExampleFromSchema(schema)).toEqual({
node: { name: "", parent: {}, leaf: { node: {} } },
});
});
test("expands the same message twice when it is not on the same path", () => {
const schema: JsonSchema = {
type: "object",
properties: {
from: { $ref: "#/$defs/example.User" },
to: { $ref: "#/$defs/example.User" },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ from: { name: "" }, to: { name: "" } });
});
test("fills every branch of a flattened oneof", () => {
const schema: JsonSchema = {
type: "object",
properties: {
id: { type: "string" },
text: { type: "string" },
image: { $ref: "#/$defs/example.Image" },
},
$defs: {
"example.Image": { type: "object", properties: { url: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
id: "",
text: "",
image: { url: "" },
});
});
test("stops expanding once the node budget runs out", () => {
// Every level references the next one twice, so an unbounded walk would build 2^depth
// nodes without ever repeating a ref on the same path.
const depth = 16;
const $defs: Record<string, JsonSchema> = { [`d${depth}`]: { type: "string" } };
for (let i = 0; i < depth; i++) {
$defs[`d${i}`] = {
type: "object",
properties: {
a: { $ref: `#/$defs/d${i + 1}` },
b: { $ref: `#/$defs/d${i + 1}` },
},
};
}
const example = buildExampleFromSchema({
type: "object",
properties: { root: { $ref: "#/$defs/d0" } },
$defs,
});
// 2 ** 16 nodes unbounded; the budget holds it to a couple of thousand
expect(countNodes(example)).toBeLessThan(10_000);
});
test("handles messages without a known type", () => {
const schema: JsonSchema = {
type: "object",
properties: {
empty: {},
struct: { type: "object" },
missing: { $ref: "#/$defs/example.Nope" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ empty: null, struct: {}, missing: {} });
});
});
function countNodes(value: unknown): number {
if (Array.isArray(value)) {
return 1 + value.reduce((total: number, v) => total + countNodes(v), 0);
}
if (value !== null && typeof value === "object") {
return 1 + Object.values(value).reduce((total: number, v) => total + countNodes(v), 0);
}
return 1;
}
-121
View File
@@ -1,121 +0,0 @@
/**
* Subset of JSON Schema emitted by the gRPC reflection layer for a method's
* input message. See `message_to_json_schema` in the `yaak-grpc` crate.
*/
export type JsonSchema = {
type?: string;
format?: string;
properties?: Record<string, JsonSchema>;
items?: JsonSchema;
additionalProperties?: JsonSchema;
enum?: unknown[];
$defs?: Record<string, JsonSchema>;
$ref?: string;
};
const DEFS_PREFIX = "#/$defs/";
const ROOT_REF = "#";
// Protobuf 64-bit integers are encoded as strings in the JSON mapping
const STRING_NUMBER_FORMATS = ["int64", "uint64", "sint64", "fixed64", "sfixed64"];
// Refs on sibling branches each expand their own subtree, so a schema that references the
// same messages repeatedly can produce exponentially many nodes without ever cycling.
const MAX_NODES = 5000;
type Budget = { remaining: number };
/** Build a sample message with placeholder values for every field in the schema */
export function buildExampleFromSchema(schema: JsonSchema): unknown {
// The root is already being built, so a `#` ref anywhere below it is a cycle
return buildValue(schema, schema, new Set([ROOT_REF]), { remaining: MAX_NODES });
}
function buildValue(
schema: JsonSchema,
root: JsonSchema,
refPath: Set<string>,
budget: Budget,
): unknown {
if (schema == null || typeof schema !== "object" || budget.remaining <= 0) {
return null;
}
budget.remaining -= 1;
if (typeof schema.$ref === "string") {
if (refPath.has(schema.$ref)) {
return {};
}
const resolved = resolveRef(schema.$ref, root);
if (resolved == null) {
return {};
}
return buildValue(resolved, root, new Set(refPath).add(schema.$ref), budget);
}
if (Array.isArray(schema.enum)) {
return schema.enum[0] ?? "";
}
switch (schema.type) {
case "object":
return buildObject(schema, root, refPath, budget);
case "array":
return schema.items == null ? [] : [buildValue(schema.items, root, refPath, budget)];
case "string":
return buildString(schema.format);
case "number":
return 0;
case "boolean":
return false;
default:
return null;
}
}
function buildObject(
schema: JsonSchema,
root: JsonSchema,
refPath: Set<string>,
budget: Budget,
): unknown {
if (schema.properties != null && typeof schema.properties === "object") {
const example: Record<string, unknown> = {};
for (const [name, propertySchema] of Object.entries(schema.properties)) {
example[name] = buildValue(propertySchema, root, refPath, budget);
}
return example;
}
// Maps have no properties, only a value schema
if (schema.additionalProperties != null) {
return { key: buildValue(schema.additionalProperties, root, refPath, budget) };
}
return {};
}
function buildString(format: string | undefined): string {
if (format === "date-time") {
return new Date().toISOString();
}
// Duration JSON is a decimal string with an `s` suffix, and an empty one fails to parse
if (format === "duration") {
return "0s";
}
if (format != null && STRING_NUMBER_FORMATS.includes(format)) {
return "0";
}
return "";
}
function resolveRef(ref: string, root: JsonSchema): JsonSchema | null {
if (ref === ROOT_REF) {
return root;
}
if (!ref.startsWith(DEFS_PREFIX)) {
return null;
}
return root.$defs?.[ref.slice(DEFS_PREFIX.length)] ?? null;
}
+6 -1
View File
@@ -25,8 +25,13 @@ export async function showPromptForm({
id,
title,
description,
hideX: true,
size: size ?? "sm",
disableClose: true,
disableBackdropClose: true, // Prevent accidental dismisses
onClose: () => {
// Click backdrop, close, or escape
resolve(null);
},
render: ({ hide }) =>
Prompt({
onCancel: () => {
+16 -14
View File
@@ -5,7 +5,6 @@ type ModelType = AnyModel["model"];
type WorkspaceRequestSettings = Pick<
Workspace,
| "settingFollowRedirects"
| "settingHttpVersion"
| "settingRequestMessageSize"
| "settingRequestTimeout"
| "settingSendCookies"
@@ -19,7 +18,9 @@ type ModelTypeWithSetting<K extends RequestSettingKey> = {
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
}[ModelType];
export type RequestSettingDefinition<K extends RequestSettingKey = RequestSettingKey> = {
export type RequestSettingDefinition<
K extends RequestSettingKey = RequestSettingKey,
> = {
defaultValue: WorkspaceRequestSettings[K];
description: string;
modelKey: K;
@@ -45,7 +46,8 @@ 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",
@@ -55,7 +57,13 @@ 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",
});
@@ -67,17 +75,10 @@ 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",
@@ -85,7 +86,8 @@ export const SETTING_SEND_COOKIES = defineRequestSetting({
export const SETTING_STORE_COOKIES = defineRequestSetting({
defaultValue: true,
description: "Save cookies from Set-Cookie response headers to the active cookie jar.",
description:
"Save cookies from Set-Cookie response headers to the active cookie jar.",
modelKey: "settingStoreCookies",
models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically store cookies",
@@ -5,20 +5,16 @@ use std::fs;
use std::io::ErrorKind;
use yaak::export::{self, ExportDataParams};
use yaak::import;
use yaak_models::util::{
BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlanAction, ImportPlanItem,
};
use yaak_core::WorkspaceContext;
use yaak_models::util::BatchUpsertResult;
use yaak_plugins::events::{ImportResources, PluginContext};
type CommandResult<T = ()> = std::result::Result<T, String>;
pub async fn run_import(ctx: &CliContext, args: ImportArgs) -> i32 {
match import(ctx, args).await {
Ok((result, items)) => {
Ok(result) => {
println!("Imported {}", format_counts(&result));
if let Some(skipped) = format_skipped(&items) {
println!("Skipped {skipped}");
}
0
}
Err(error) => {
@@ -41,10 +37,7 @@ pub fn run_export(ctx: &CliContext, args: ExportArgs) -> i32 {
}
}
async fn import(
ctx: &CliContext,
args: ImportArgs,
) -> CommandResult<(BatchUpsertResult, Vec<ImportPlanItem>)> {
async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsertResult> {
if let Some(workspace_id) = args.workspace_id.as_deref() {
ctx.db()
.get_workspace(workspace_id)
@@ -58,7 +51,6 @@ async fn import(
.import_data(&plugin_context, &file_contents)
.await
.map_err(|e| format!("Failed to import data: {e}"))?;
let importer = import_result.importer;
let resources = import_result.resources;
let workspace_id = args.workspace_id;
if workspace_id.is_none() && resources_need_current_workspace(&resources) {
@@ -67,62 +59,15 @@ async fn import(
.to_string(),
);
}
let destination = match workspace_id {
Some(workspace_id) => ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
None => ImportDestination::NewWorkspace,
let workspace_context = WorkspaceContext {
workspace_id,
environment_id: None,
cookie_jar_id: None,
request_id: None,
};
let plan = import::plan_import_resources(
ctx.query_manager(),
importer,
destination,
resources,
import_result.source_keys,
Some(file_origin(&args.file)),
)
.map_err(|e| format!("Failed to plan import: {e}"))?;
let items = plan.items.clone();
let imported = import::commit_import_plan(ctx.query_manager(), plan)
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources)
.map_err(|e| format!("Failed to import data: {e}"))?;
Ok((imported, items))
}
fn file_origin(path: &std::path::Path) -> ImportOrigin {
let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let label = canonical
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| path.display().to_string());
ImportOrigin { origin: canonical.to_string_lossy().to_string(), label }
}
/// Summarize what the default selection left untouched during a merging re-import.
fn format_skipped(items: &[ImportPlanItem]) -> Option<String> {
let count = |action: ImportPlanAction| items.iter().filter(|i| i.action == action).count();
let plural = |n: usize| if n == 1 { "" } else { "s" };
let mut parts = Vec::new();
let deletions = count(ImportPlanAction::Delete);
if deletions > 0 {
parts.push(format!("{deletions} removed from source (not deleted locally)"));
}
let conflicts = count(ImportPlanAction::Conflict);
if conflicts > 0 {
parts.push(format!("{conflicts} conflict{} (kept local changes)", plural(conflicts)));
}
let keep_local = count(ImportPlanAction::KeepLocal);
if keep_local > 0 {
parts.push(format!("{keep_local} with local edits"));
}
let not_imported = count(ImportPlanAction::NotImported);
if not_imported > 0 {
parts.push(format!("{not_imported} previously not imported"));
}
let unchanged = count(ImportPlanAction::Unchanged);
if unchanged > 0 {
parts.push(format!("{unchanged} unchanged"));
}
if parts.is_empty() { None } else { Some(parts.join(", ")) }
Ok(imported)
}
fn export(ctx: &CliContext, args: ExportArgs) -> CommandResult<usize> {
@@ -4,7 +4,6 @@ use common::{cli_cmd, parse_created_id, query_manager, seed_request};
use predicates::str::contains;
use serde_json::Value;
use tempfile::TempDir;
use yaak_models::util::UpdateSource;
#[test]
fn export_writes_yaak_workspace_file() {
@@ -82,21 +81,14 @@ fn import_reads_yaak_workspace_file() {
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
let workspaces = db.list_workspaces().expect("list imported workspaces");
let workspace = workspaces
.iter()
.find(|workspace| workspace.name == "Imported Workspace")
.expect("workspace imported");
assert_ne!(workspace.id, "wrk_import");
let requests = db.list_http_requests(&workspace.id).expect("list imported requests");
let request = requests
.iter()
.find(|request| request.name == "Imported Request")
.expect("request imported");
assert_ne!(request.id, "req_import");
assert_eq!(request.workspace_id, workspace.id);
assert_eq!(request.url, "https://example.com");
assert_eq!(
db.get_workspace("wrk_import").expect("workspace imported").name,
"Imported Workspace"
);
assert_eq!(
db.get_http_request("req_import").expect("request imported").url,
"https://example.com"
);
}
fn write_postman_environment_fixture(path: &std::path::Path) {
@@ -168,150 +160,3 @@ fn import_postman_environment_uses_workspace_id() {
environments.iter().find(|e| e.name == "Local").expect("postman environment imported");
assert_eq!(imported_environment.workspace_id, workspace_id);
}
fn write_linked_fixture(path: &std::path::Path, requests: &[(&str, &str, &str)]) {
let requests = requests
.iter()
.map(|(id, name, url)| {
format!(
r#"{{ "model": "http_request", "id": "{id}", "workspaceId": "wrk_link",
"name": "{name}", "method": "GET", "url": "{url}" }}"#
)
})
.collect::<Vec<_>>()
.join(",");
std::fs::write(
path,
format!(
r#"{{
"yaakVersion": "test",
"yaakSchema": 4,
"resources": {{
"workspaces": [{{ "model": "workspace", "id": "wrk_link", "name": "Linked Workspace" }}],
"httpRequests": [{requests}]
}}
}}"#
),
)
.expect("write linked fixture");
}
#[test]
fn re_import_merges_into_linked_workspace() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
let import_path = temp_dir.path().join("linked.json");
write_linked_fixture(
&import_path,
&[
("req_a", "Request A", "https://example.com/a"),
("req_b", "Request B", "https://example.com/b"),
],
);
cli_cmd(data_dir)
.args(["import", import_path.to_str().expect("import path is utf-8")])
.assert()
.success()
.stdout(contains("Imported 1 workspace, 2 HTTP requests"));
let workspace_id = {
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
db.list_workspaces()
.expect("list workspaces")
.into_iter()
.find(|w| w.name == "Linked Workspace")
.expect("workspace imported")
.id
};
// The source doc changes A, drops B, and adds C. The default selection applies the
// update and the create but leaves the removal as an offer.
write_linked_fixture(
&import_path,
&[
("req_a", "Request A", "https://example.com/a-v2"),
("req_c", "Request C", "https://example.com/c"),
],
);
cli_cmd(data_dir)
.args([
"import",
import_path.to_str().expect("import path is utf-8"),
"--workspace-id",
&workspace_id,
])
.assert()
.success()
.stdout(contains("Imported 2 HTTP requests"))
.stdout(contains("Skipped 1 removed from source"));
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
let requests = db.list_http_requests(&workspace_id).expect("list requests");
assert_eq!(requests.len(), 3, "merge must not duplicate: {requests:?}");
assert_eq!(
requests.iter().find(|r| r.name == "Request A").expect("request A").url,
"https://example.com/a-v2"
);
assert!(requests.iter().any(|r| r.name == "Request B"), "removal must not auto-apply");
assert!(requests.iter().any(|r| r.name == "Request C"));
}
#[test]
fn re_import_leaves_deleted_resources_alone() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
let import_path = temp_dir.path().join("linked.json");
write_linked_fixture(
&import_path,
&[
("req_a", "Request A", "https://example.com/a"),
("req_b", "Request B", "https://example.com/b"),
],
);
cli_cmd(data_dir)
.args(["import", import_path.to_str().expect("import path is utf-8")])
.assert()
.success();
let workspace_id = {
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
let workspace_id = db
.list_workspaces()
.expect("list workspaces")
.into_iter()
.find(|w| w.name == "Linked Workspace")
.expect("workspace imported")
.id;
let request_b = db
.list_http_requests(&workspace_id)
.expect("list requests")
.into_iter()
.find(|r| r.name == "Request B")
.expect("request B imported");
db.delete_http_request_by_id(&request_b.id, &UpdateSource::Sync)
.expect("delete request B");
workspace_id
};
cli_cmd(data_dir)
.args([
"import",
import_path.to_str().expect("import path is utf-8"),
"--workspace-id",
&workspace_id,
])
.assert()
.success()
.stdout(contains("Skipped 1 previously not imported"));
let query_manager = query_manager(data_dir);
let requests =
query_manager.connect().list_http_requests(&workspace_id).expect("list requests");
assert_eq!(requests.len(), 1, "a deleted request must not come back: {requests:?}");
assert_eq!(requests[0].name, "Request A");
}
+24 -111
View File
@@ -1,135 +1,48 @@
// 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;
settingHttpVersion: InheritedHttpVersionSetting;
};
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 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;
httpVersion: HttpVersion;
};
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 InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
export type InheritedIntSetting = { enabled?: boolean; value: number };
export type InheritedIntSetting = { enabled?: boolean, value: number, };
-1
View File
@@ -157,7 +157,6 @@ 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,
+6 -28
View File
@@ -1,37 +1,15 @@
// 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;
/**
* 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 UpdateInfo = { replyEventId: string, version: string, downloaded: boolean, };
/**
* 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 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, };
+3 -2
View File
@@ -3,10 +3,11 @@ use std::collections::BTreeMap;
use crate::PluginContextExt;
use crate::error::Result;
use KeyAndValueRef::{Ascii, Binary};
use tauri::{Runtime, WebviewWindow};
use tauri::{Manager, Runtime, WebviewWindow};
use yaak_grpc::{KeyAndValueRef, MetadataMap};
use yaak_models::models::GrpcRequest;
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader};
use yaak_plugins::manager::PluginManager;
pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String> {
let mut entries = BTreeMap::new();
@@ -25,7 +26,7 @@ pub(crate) async fn build_metadata<R: Runtime>(
request: &GrpcRequest,
authentication_context_id: &str,
) -> Result<BTreeMap<String, String>> {
let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
let plugin_manager = window.state::<PluginManager>();
let mut metadata = BTreeMap::new();
// Add the rest of metadata
@@ -14,6 +14,7 @@ use yaak_http::manager::HttpConnectionManager;
use yaak_models::models::{CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseState};
use yaak_models::util::UpdateSource;
use yaak_plugins::events::PluginContext;
use yaak_plugins::manager::PluginManager;
/// Context for managing response state during HTTP transactions.
/// Handles both persisted responses (stored in DB) and ephemeral responses (in-memory only).
@@ -148,7 +149,7 @@ async fn send_http_request_inner<R: Runtime>(
response_ctx: &mut ResponseContext<R>,
) -> Result<SentHttpRequest> {
let app_handle = window.app_handle().clone();
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let connection_manager = app_handle.state::<HttpConnectionManager>();
let environment_id = environment.map(|e| e.id);
+22 -53
View File
@@ -4,84 +4,53 @@ use crate::models_ext::QueryManagerExt;
use std::fs::read_to_string;
use std::io::ErrorKind;
use tauri::{Manager, Runtime, WebviewWindow};
use yaak::import::{self, PlanImportDataParams};
use yaak::import::{self, ImportDataParams};
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlan};
use yaak_core::WorkspaceContext;
use yaak_models::util::BatchUpsertResult;
use yaak_plugins::manager::PluginManager;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
pub(crate) async fn import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
origin: Option<ImportOrigin>,
) -> Result<BatchUpsertResult> {
let contents = read_import_file(file_path)?;
let plan =
plan_import_contents(window, &contents, ImportDestination::NewWorkspace, origin).await?;
commit_import(window, plan)
import_contents(window, &contents).await
}
pub(crate) async fn plan_import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
destination: ImportDestination,
) -> Result<ImportPlan> {
let contents = read_import_file(file_path)?;
plan_import_contents(window, &contents, destination, Some(file_origin(file_path))).await
}
pub(crate) async fn plan_import_url<R: Runtime>(
pub(crate) async fn import_url<R: Runtime>(
window: &WebviewWindow<R>,
url: &str,
destination: ImportDestination,
) -> Result<ImportPlan> {
let url = normalize_import_url(url)?;
let contents = fetch_import_url(window, &url).await?;
plan_import_contents(window, &contents, destination, Some(url_origin(&url))).await
) -> Result<BatchUpsertResult> {
let contents = fetch_import_url(window, url).await?;
import_contents(window, &contents).await
}
async fn plan_import_contents<R: Runtime>(
async fn import_contents<R: Runtime>(
window: &WebviewWindow<R>,
contents: &str,
destination: ImportDestination,
origin: Option<ImportOrigin>,
) -> Result<ImportPlan> {
let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
) -> Result<BatchUpsertResult> {
let plugin_manager = window.state::<PluginManager>();
let query_manager = window.db_manager();
let plugin_context = window.plugin_context();
let workspace_context = WorkspaceContext {
workspace_id: window.workspace_id(),
environment_id: window.environment_id(),
cookie_jar_id: window.cookie_jar_id(),
request_id: None,
};
Ok(import::plan_import_data(PlanImportDataParams {
Ok(import::import_data(ImportDataParams {
query_manager: &query_manager,
plugin_manager: &plugin_manager,
plugin_context: &plugin_context,
destination,
workspace_context,
contents,
origin,
})
.await?)
}
/// Canonicalize so re-importing the same file through a different spelling of its path still
/// matches the linked source.
pub(crate) fn file_origin(file_path: &str) -> ImportOrigin {
let path = std::path::Path::new(file_path);
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let label = canonical
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| file_path.to_string());
ImportOrigin { origin: canonical.to_string_lossy().to_string(), label }
}
pub(crate) fn url_origin(url: &str) -> ImportOrigin {
ImportOrigin { origin: url.to_string(), label: url.to_string() }
}
pub(crate) fn commit_import<R: Runtime>(
window: &WebviewWindow<R>,
plan: ImportPlan,
) -> Result<BatchUpsertResult> {
Ok(import::commit_import_plan(&window.db_manager(), plan)?)
}
/// Download an importable document (OpenAPI, Postman, Insomnia, …) so it can be fed to the same
/// pipeline as a file on disk.
///
@@ -110,7 +79,7 @@ async fn fetch_import_url<R: Runtime>(window: &WebviewWindow<R>, url: &str) -> R
.map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}")))
}
pub(crate) fn normalize_import_url(url: &str) -> Result<String> {
fn normalize_import_url(url: &str) -> Result<String> {
let url = url.trim();
if url.is_empty() {
return Err(Error::GenericError("Import URL must not be empty".to_string()));
+14 -39
View File
@@ -4,7 +4,7 @@ use crate::error::Error::GenericError;
use crate::error::Result;
use crate::grpc::{build_metadata, metadata_to_map};
use crate::http_request::send_http_request;
use crate::import::{commit_import, plan_import_data, plan_import_url};
use crate::import::{import_data, import_url};
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_template};
@@ -40,11 +40,12 @@ use yaak_models::models::{
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
};
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, UpdateSource};
use yaak_models::util::{BatchUpsertResult, UpdateSource};
use yaak_plugins::events::{
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
RenderPurpose, ShowToastRequest,
};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_rpc_schema::{AppMetaData, EphemeralHttpResponse};
use yaak_sse::sse::ServerSentEvent;
@@ -249,7 +250,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
let resolved_settings =
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let req = render_grpc_request(
&resolved_request,
@@ -309,7 +310,7 @@ async fn cmd_grpc_go<R: Runtime>(
let resolved_settings =
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let request = render_grpc_request(
&resolved_request,
@@ -961,6 +962,7 @@ async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
async fn cmd_http_response_body<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
response_id: &str,
filter: Option<&str>,
) -> YaakResult<FilterResponse> {
@@ -975,8 +977,7 @@ async fn cmd_http_response_body<R: Runtime>(
.ok_or(GenericError("Failed to find response body".to_string()))?;
match filter {
Some(filter) if !filter.is_empty() => Ok(plugins_ext::plugin_manager(&window)
.await?
Some(filter) if !filter.is_empty() => Ok(plugin_manager
.filter_data(&window.plugin_context(), filter, &body, content_type)
.await?),
_ => Ok(FilterResponse { content: body, error: None }),
@@ -1013,24 +1014,15 @@ async fn cmd_get_sse_events<R: Runtime>(
async fn cmd_import_data<R: Runtime>(
window: WebviewWindow<R>,
file_path: &str,
destination: ImportDestination,
) -> YaakResult<ImportPlan> {
plan_import_data(&window, file_path, destination).await
) -> YaakResult<BatchUpsertResult> {
import_data(&window, file_path).await
}
async fn cmd_import_url<R: Runtime>(
window: WebviewWindow<R>,
url: &str,
destination: ImportDestination,
) -> YaakResult<ImportPlan> {
plan_import_url(&window, url, destination).await
}
async fn cmd_commit_import<R: Runtime>(
window: WebviewWindow<R>,
plan: ImportPlan,
) -> YaakResult<BatchUpsertResult> {
commit_import(&window, plan)
import_url(&window, url).await
}
@@ -1375,16 +1367,6 @@ 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) =
@@ -1449,10 +1431,7 @@ fn safe_uri(endpoint: &str) -> String {
fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
let app_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
let plugin_manager = match plugins_ext::plugin_manager(&app_handle).await {
Ok(pm) => pm,
Err(_) => return, // The runtime failed to boot; there are no events
};
let plugin_manager: State<'_, PluginManager> = app_handle.state();
let (rx_id, mut rx) = plugin_manager.subscribe("app").await;
while let Some(event) = rx.recv().await {
@@ -1493,13 +1472,9 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
}
};
match plugins_ext::plugin_manager(&app_handle).await {
Ok(pm) => {
if let Err(e) = pm.reply(&event, &ev).await {
warn!("Failed to reply to plugin manager: {:?}", e)
}
}
Err(e) => warn!("Failed to get plugin manager for reply: {e:?}"),
let plugin_manager: State<'_, PluginManager> = app_handle.state();
if let Err(e) = plugin_manager.reply(&event, &ev).await {
warn!("Failed to reply to plugin manager: {:?}", e)
}
});
}
@@ -32,6 +32,7 @@ use yaak_plugins::events::{
ShowToastRequest, TemplateRenderResponse, WindowInfoResponse, WindowNavigateEvent,
WorkspaceInfo,
};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_handle::PluginHandle;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
@@ -204,7 +205,7 @@ async fn handle_host_plugin_request<R: Runtime>(
req.grpc_request.folder_id.as_deref(),
environment_id.as_deref(),
)?;
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let cb = PluginTemplateCallback::new(
plugin_manager,
@@ -230,7 +231,7 @@ async fn handle_host_plugin_request<R: Runtime>(
req.http_request.folder_id.as_deref(),
environment_id.as_deref(),
)?;
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let cb = PluginTemplateCallback::new(
plugin_manager,
@@ -266,7 +267,7 @@ async fn handle_host_plugin_request<R: Runtime>(
folder_id.as_deref(),
environment_id.as_deref(),
)?;
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let cb = PluginTemplateCallback::new(
plugin_manager,
+18 -77
View File
@@ -31,43 +31,10 @@ use yaak_plugins::api::{
use yaak_plugins::events::{Color, PluginContext, ShowToastRequest};
use yaak_plugins::install::{delete_and_uninstall, download_and_install};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::error::Error::PluginErr;
use yaak_plugins::plugin_meta::get_plugin_meta;
static EXITING: AtomicBool = AtomicBool::new(false);
// ============================================================================
// Plugin Manager Handle
// ============================================================================
/// The plugin runtime boots in the background so startup doesn't wait on it.
/// This handle is the only way to reach the manager: [`PluginManagerHandle::get`]
/// resolves once boot completes, so callers can never observe a
/// partially-initialized runtime.
#[derive(Clone)]
pub struct PluginManagerHandle {
rx: tokio::sync::watch::Receiver<Option<std::result::Result<PluginManager, String>>>,
}
impl PluginManagerHandle {
pub async fn get(&self) -> yaak_plugins::error::Result<PluginManager> {
let mut rx = self.rx.clone();
let result = rx
.wait_for(|v| v.is_some())
.await
.map_err(|_| PluginErr("Plugin runtime boot task died".to_string()))?;
result.clone().unwrap().map_err(PluginErr)
}
}
/// Wait for the plugin runtime to finish booting and return the manager.
pub async fn plugin_manager<R: Runtime>(
manager: &impl Manager<R>,
) -> yaak_plugins::error::Result<PluginManager> {
let handle = manager.state::<PluginManagerHandle>().inner().clone();
handle.get().await
}
// ============================================================================
// Plugin Updater
// ============================================================================
@@ -179,7 +146,7 @@ pub async fn cmd_plugins_install<R: Runtime>(
name: &str,
version: Option<String>,
) -> Result<()> {
let plugin_manager = Arc::new(plugin_manager(&window).await?);
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
let app_version = window.app_handle().package_info().version.to_string();
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
@@ -200,9 +167,6 @@ pub async fn cmd_plugins_install_from_directory<R: Runtime>(
window: WebviewWindow<R>,
directory: &str,
) -> Result<Plugin> {
// Resolve the manager before writing the row so startup's plugin snapshot
// can't include it and boot it a second time
let plugin_manager = Arc::new(plugin_manager(&window).await?);
let plugin = window.db().upsert_plugin(
&Plugin {
directory: directory.into(),
@@ -214,6 +178,7 @@ pub async fn cmd_plugins_install_from_directory<R: Runtime>(
&UpdateSource::from_window_label(window.label()),
)?;
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
plugin_manager.add_plugin(&window.plugin_context(), &plugin).await?;
Ok(plugin)
@@ -223,7 +188,7 @@ pub async fn cmd_plugins_uninstall<R: Runtime>(
plugin_id: &str,
window: WebviewWindow<R>,
) -> Result<Plugin> {
let plugin_manager = Arc::new(plugin_manager(&window).await?);
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
let plugin_context = window.plugin_context();
Ok(delete_and_uninstall(plugin_manager, &query_manager, &plugin_context, plugin_id).await?)
@@ -252,7 +217,7 @@ pub async fn cmd_plugins_update_all<R: Runtime>(
return Ok(Vec::new());
}
let plugin_manager = Arc::new(plugin_manager(&window).await?);
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
let plugin_context = window.plugin_context();
@@ -335,38 +300,20 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
let query_manager =
app_handle.state::<yaak_models::query_manager::QueryManager>().inner().clone();
// Boot the plugin runtime in the background so the window shows
// immediately. Everything that needs plugins resolves the handle,
// which waits for this task to finish.
let (tx, rx) = tokio::sync::watch::channel(None);
app_handle.manage(PluginManagerHandle { rx });
// Create plugin manager asynchronously
let app_handle_clone = app_handle.clone();
tauri::async_runtime::spawn(async move {
let result = tokio::time::timeout(
Duration::from_secs(60),
PluginManager::new(
vendored_plugin_dir,
installed_plugin_dir,
node_bin_path,
plugin_runtime_main,
&query_manager,
&PluginContext::new_empty(),
dev_mode,
),
tauri::async_runtime::block_on(async move {
let manager = PluginManager::new(
vendored_plugin_dir,
installed_plugin_dir,
node_bin_path,
plugin_runtime_main,
&query_manager,
&PluginContext::new_empty(),
dev_mode,
)
.await
.unwrap_or_else(|_| Err(yaak_plugins::error::Error::PluginErr(
"Timed out starting the plugin runtime".to_string(),
)));
let manager = match result {
Ok(manager) => manager,
Err(e) => {
error!("Failed to start plugin runtime: {e:?}");
let _ = tx.send(Some(Err(e.to_string())));
return;
}
};
.expect("Failed to start plugin runtime");
// Surface unexpected runtime crashes to the user
let mut crash_rx = manager.runtime_crash_rx();
@@ -392,7 +339,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
}
});
let _ = tx.send(Some(Ok(manager)));
app_handle_clone.manage(manager);
});
let plugin_updater = PluginUpdater::new();
@@ -408,14 +355,8 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
api.prevent_exit();
tauri::async_runtime::block_on(async move {
info!("Exiting plugin runtime due to app exit");
// Bound the wait in case the exit comes while boot is still
// in flight
let get_manager = plugin_manager(app);
if let Ok(Ok(manager)) =
tokio::time::timeout(Duration::from_secs(5), get_manager).await
{
manager.terminate().await;
}
let manager: State<PluginManager> = app.state();
manager.terminate().await;
app.exit(0);
});
}
+42 -75
View File
@@ -37,11 +37,10 @@ use yaak_grpc::ServiceDefinition;
use yaak_models::blob_manager::BlobManager;
use yaak_models::models::{
GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent,
WorkspaceMeta,
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{BatchUpsertResult, ImportPlan};
use yaak_models::util::BatchUpsertResult;
use yaak_plugins::events::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
@@ -110,11 +109,10 @@ impl<R: Runtime> Host for ClientCtx<R> {
}
impl<R: Runtime> ClientCtx<R> {
/// The plugin runtime this window talks to, once it finishes booting.
/// Only the `PluginHost` impl below uses it; everything else goes through
/// the trait.
async fn pm(&self) -> yaak_plugins::error::Result<PluginManager> {
crate::plugins_ext::plugin_manager(&self.window).await
/// The plugin runtime this window talks to. Only the `PluginHost` impl
/// below uses it; everything else goes through the trait.
fn pm(&self) -> State<'_, PluginManager> {
self.window.state::<PluginManager>()
}
}
@@ -124,40 +122,35 @@ impl<R: Runtime> ClientCtx<R> {
/// providing them.
impl<R: Runtime> PluginHost for ClientCtx<R> {
async fn loaded_plugin_metadata(&self, directory: &str) -> Option<PluginMetadata> {
let handle = self.pm().await.ok()?.get_plugin_by_dir(directory).await?;
let handle = self.pm().get_plugin_by_dir(directory).await?;
Some(handle.info())
}
async fn take_plugin_init_errors(&self) -> Vec<(String, String)> {
match self.pm().await {
Ok(pm) => pm.take_init_errors().await,
Err(_) => Vec::new(),
}
self.pm().take_init_errors().await
}
async fn resolve_plugins(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {
match self.pm().await {
Ok(pm) => pm.resolve_plugins_for_runtime_from_db(plugins).await,
Err(_) => plugins,
}
self.pm().resolve_plugins_for_runtime_from_db(plugins).await
}
async fn template_callback(
&self,
purpose: RenderPurpose,
) -> yaak_commands::Result<impl TemplateCallback> {
Ok(PluginTemplateCallback::new(
Arc::new(self.pm().await?),
fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback {
PluginTemplateCallback::new(
Arc::new((*self.pm()).clone()),
Arc::new(self.encryption_manager().clone()),
&self.plugin_context(),
purpose,
))
)
}
async fn template_function_summaries(
&self,
) -> yaak_commands::Result<Vec<GetTemplateFunctionSummaryResponse>> {
Ok(self.pm().await?.get_template_function_summaries(&self.plugin_context()).await?)
Ok(self
.window
.state::<PluginManager>()
.get_template_function_summaries(&self.plugin_context())
.await?)
}
async fn template_function_config(
@@ -167,81 +160,81 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
model_id: &str,
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
Ok(self
.pm()
.await?
.window
.state::<PluginManager>()
.get_template_function_config(&self.plugin_context(), function_name, values, model_id)
.await?)
}
async fn themes(&self) -> yaak_commands::Result<Vec<GetThemesResponse>> {
Ok(self.pm().await?.get_themes(&self.plugin_context()).await?)
Ok(self.pm().get_themes(&self.plugin_context()).await?)
}
async fn http_request_actions(
&self,
) -> yaak_commands::Result<Vec<GetHttpRequestActionsResponse>> {
Ok(self.pm().await?.get_http_request_actions(&self.plugin_context()).await?)
Ok(self.pm().get_http_request_actions(&self.plugin_context()).await?)
}
async fn websocket_request_actions(
&self,
) -> yaak_commands::Result<Vec<GetWebsocketRequestActionsResponse>> {
Ok(self.pm().await?.get_websocket_request_actions(&self.plugin_context()).await?)
Ok(self.pm().get_websocket_request_actions(&self.plugin_context()).await?)
}
async fn grpc_request_actions(
&self,
) -> yaak_commands::Result<Vec<GetGrpcRequestActionsResponse>> {
Ok(self.pm().await?.get_grpc_request_actions(&self.plugin_context()).await?)
Ok(self.pm().get_grpc_request_actions(&self.plugin_context()).await?)
}
async fn workspace_actions(&self) -> yaak_commands::Result<Vec<GetWorkspaceActionsResponse>> {
Ok(self.pm().await?.get_workspace_actions(&self.plugin_context()).await?)
Ok(self.pm().get_workspace_actions(&self.plugin_context()).await?)
}
async fn folder_actions(&self) -> yaak_commands::Result<Vec<GetFolderActionsResponse>> {
Ok(self.pm().await?.get_folder_actions(&self.plugin_context()).await?)
Ok(self.pm().get_folder_actions(&self.plugin_context()).await?)
}
async fn call_http_request_action(
&self,
req: CallHttpRequestActionRequest,
) -> yaak_commands::Result<()> {
Ok(self.pm().await?.call_http_request_action(&self.plugin_context(), req).await?)
Ok(self.pm().call_http_request_action(&self.plugin_context(), req).await?)
}
async fn call_grpc_request_action(
&self,
req: CallGrpcRequestActionRequest,
) -> yaak_commands::Result<()> {
Ok(self.pm().await?.call_grpc_request_action(&self.plugin_context(), req).await?)
Ok(self.pm().call_grpc_request_action(&self.plugin_context(), req).await?)
}
async fn call_websocket_request_action(
&self,
req: CallWebsocketRequestActionRequest,
) -> yaak_commands::Result<()> {
Ok(self.pm().await?.call_websocket_request_action(&self.plugin_context(), req).await?)
Ok(self.pm().call_websocket_request_action(&self.plugin_context(), req).await?)
}
async fn call_workspace_action(
&self,
req: CallWorkspaceActionRequest,
) -> yaak_commands::Result<()> {
Ok(self.pm().await?.call_workspace_action(&self.plugin_context(), req).await?)
Ok(self.pm().call_workspace_action(&self.plugin_context(), req).await?)
}
async fn call_folder_action(
&self,
req: CallFolderActionRequest,
) -> yaak_commands::Result<()> {
Ok(self.pm().await?.call_folder_action(&self.plugin_context(), req).await?)
Ok(self.pm().call_folder_action(&self.plugin_context(), req).await?)
}
async fn http_authentication_summaries(
&self,
) -> yaak_commands::Result<Vec<GetHttpAuthenticationSummaryResponse>> {
let results = self.pm().await?.get_http_authentication_summaries(&self.plugin_context()).await?;
let results = self.pm().get_http_authentication_summaries(&self.plugin_context()).await?;
Ok(results.into_iter().map(|(_, a)| a).collect())
}
@@ -253,7 +246,6 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
) -> yaak_commands::Result<GetHttpAuthenticationConfigResponse> {
Ok(self
.pm()
.await?
.get_http_authentication_config(&self.plugin_context(), auth_name, values, model_id)
.await?)
}
@@ -267,7 +259,6 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
) -> yaak_commands::Result<()> {
Ok(self
.pm()
.await?
.call_http_authentication_action(
&self.plugin_context(),
auth_name,
@@ -279,18 +270,15 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
}
async fn import_data(&self, content: &str) -> yaak_commands::Result<ImportResponse> {
Ok(self.pm().await?.import_data(&self.plugin_context(), content).await?)
Ok(self.pm().import_data(&self.plugin_context(), content).await?)
}
async fn reload_plugins(&self, plugins: Vec<Plugin>) -> Vec<(String, String)> {
match self.pm().await {
Ok(pm) => pm.initialize_all_plugins(plugins, &self.plugin_context()).await,
Err(e) => vec![("*".to_string(), e.to_string())],
}
self.pm().initialize_all_plugins(plugins, &self.plugin_context()).await
}
async fn encrypt_secure_template(&self, template: &str) -> yaak_commands::Result<String> {
let plugin_manager = Arc::new(self.pm().await?);
let plugin_manager = Arc::new((*self.pm()).clone());
let encryption_manager = Arc::new(self.encryption_manager().clone());
Ok(encrypt_secure_template_function(
plugin_manager,
@@ -434,7 +422,7 @@ async fn cmd_format_graphql<R: Runtime>(_ctx: ClientCtx<R>, req: CmdFormatGraphq
}
async fn cmd_http_response_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyReq) -> Result<FilterResponse> {
Ok(crate::cmd_http_response_body(ctx.window.clone(), &req.response_id, req.filter.as_deref()).await?)
Ok(crate::cmd_http_response_body(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), &req.response_id, req.filter.as_deref()).await?)
}
async fn cmd_http_response_body_path<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyPathReq) -> Result<Option<String>> {
@@ -453,34 +441,12 @@ async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGet
Ok(yaak_commands::responses::cmd_get_http_response_events(ctx, req).await?)
}
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<ImportPlan> {
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path, req.destination).await?)
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path).await?)
}
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<ImportPlan> {
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url, req.destination).await?)
}
async fn cmd_commit_import<R: Runtime>(ctx: ClientCtx<R>, req: CmdCommitImportReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_commit_import(ctx.window.clone(), req.plan).await?)
}
async fn cmd_list_import_sources<R: Runtime>(ctx: ClientCtx<R>, req: CmdListImportSourcesReq) -> Result<Vec<ImportSource>> {
use crate::models_ext::QueryManagerExt;
Ok(ctx.window.db().list_import_sources(&req.workspace_id)?)
}
async fn cmd_import_sources_for_origin<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportSourcesForOriginReq) -> Result<Vec<ImportSource>> {
use crate::models_ext::QueryManagerExt;
let origin = match (req.file_path, req.url) {
(Some(file_path), _) => crate::import::file_origin(&file_path).origin,
(None, Some(url)) => match crate::import::normalize_import_url(&url) {
Ok(url) => crate::import::url_origin(&url).origin,
Err(_) => return Ok(Vec::new()),
},
(None, None) => return Ok(Vec::new()),
};
Ok(ctx.window.db().list_import_sources_by_origin(&origin)?)
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url).await?)
}
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
@@ -847,7 +813,7 @@ async fn cmd_ws_close<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsCloseReq) -> Resu
}
async fn cmd_ws_connect<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsConnectReq) -> Result<WebsocketConnection> {
Ok(crate::ws_ext::cmd_ws_connect(&req.request_id, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<Mutex<WebsocketManager>>()).await?)
Ok(crate::ws_ext::cmd_ws_connect(&req.request_id, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), ctx.window.app_handle().state::<Mutex<WebsocketManager>>()).await?)
}
async fn cmd_plugins_search<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsSearchReq) -> Result<PluginSearchResponse> {
@@ -877,3 +843,4 @@ async fn cmd_plugins_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpda
async fn cmd_plugins_update_all<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdateAllReq) -> Result<Vec<PluginNameVersion>> {
Ok(crate::plugins_ext::cmd_plugins_update_all(ctx.window.clone()).await?)
}
+14 -187
View File
@@ -14,6 +14,7 @@ use tokio::task::block_in_place;
use tokio::time::sleep;
use ts_rs::TS;
use yaak_models::util::generate_id;
use yaak_plugins::manager::PluginManager;
use url::Url;
use yaak_api::get_system_proxy_url;
@@ -75,6 +76,14 @@ 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());
@@ -97,9 +106,8 @@ impl YaakUpdater {
block_in_place(|| {
tauri::async_runtime::block_on(async move {
info!("Shutting down plugin manager before update");
if let Ok(plugin_manager) = crate::plugins_ext::plugin_manager(&w).await {
plugin_manager.terminate().await;
}
let plugin_manager = w.state::<PluginManager>();
plugin_manager.terminate().await;
});
});
})
@@ -122,18 +130,6 @@ 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()
@@ -211,23 +207,6 @@ 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)]
@@ -293,12 +272,8 @@ 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,
install: UpdateInstall::Integrated,
reply_event_id: reply_id,
};
let info =
UpdateInfo { version: update.version.to_string(), downloaded, reply_event_id: reply_id };
window
.emit_to(window.label(), "update_available", &info)
.map_err(|e| GenericError(format!("Failed to emit update_available: {e}")))?;
@@ -331,24 +306,6 @@ 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
@@ -419,137 +376,7 @@ fn detect_install_mode() -> Option<&'static str> {
return Some("nsis");
}
#[allow(unreachable_code)]
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}")));
}
}
None
}
pub async fn install_update_maybe_download<R: Runtime>(
@@ -1,6 +1,6 @@
use crate::PluginContextExt;
use crate::error::Result;
use crate::import::{file_origin, import_data, url_origin};
use crate::import::import_data;
use crate::models_ext::QueryManagerExt;
use log::{info, warn};
use std::collections::HashMap;
@@ -12,6 +12,7 @@ use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_models::util::generate_id;
use yaak_plugins::events::{Color, ShowToastRequest};
use yaak_plugins::install::download_and_install;
use yaak_plugins::manager::PluginManager;
pub(crate) async fn handle_deep_link<R: Runtime>(
app_handle: &AppHandle<R>,
@@ -43,7 +44,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
return Ok(());
}
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(window).await?);
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
let query_manager = app_handle.db_manager();
let app_version = app_handle.package_info().version.to_string();
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
@@ -69,7 +70,6 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
}
"import-data" => {
let mut file_path = query_map.get("path").map(|s| s.to_owned());
let mut origin = None;
let name = query_map.get("name").map(|s| s.to_owned()).unwrap_or("data".to_string());
_ = window.set_focus();
@@ -99,7 +99,6 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
.to_string();
fs::write(&p, json)?;
file_path = Some(p);
origin = Some(url_origin(file_url));
}
let file_path = match file_path {
@@ -118,8 +117,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
}
};
let origin = origin.unwrap_or_else(|| file_origin(&file_path));
let results = import_data(window, &file_path, Some(origin)).await?;
let results = import_data(window, &file_path).await?;
window.emit(
"show_toast",
ShowToastRequest {
+4 -2
View File
@@ -22,6 +22,7 @@ use yaak_models::models::{
};
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
use yaak_templates::{RenderErrorBehavior, RenderOptions};
@@ -76,7 +77,7 @@ async fn send_websocket_message<R: Runtime>(
)?;
let (resolved_request, _auth_context_id) =
resolve_websocket_request(&window.db(), &unrendered_request)?;
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let request = render_websocket_request(
&resolved_request,
@@ -141,6 +142,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
cookie_jar_id: Option<&str>,
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
_plugin_manager: State<'_, PluginManager>,
ws_manager: State<'_, Mutex<WebsocketManager>>,
) -> Result<WebsocketConnection> {
let unrendered_request = app_handle.db().get_websocket_request(request_id)?;
@@ -154,7 +156,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
let settings = app_handle.db().get_settings();
let (resolved_request, auth_context_id) =
resolve_websocket_request(&window.db(), &unrendered_request)?;
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let request = render_websocket_request(
&resolved_request,
@@ -4,14 +4,9 @@ version = "0.1.0"
edition = "2024"
publish = false
[target.'cfg(target_os = "linux")'.dependencies]
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.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 }
+12 -74
View File
@@ -1,5 +1,5 @@
use std::sync::{Arc, Mutex};
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "macos"))]
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(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "macos"))]
const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -47,12 +47,14 @@ 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(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "macos"))]
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),
@@ -64,69 +66,11 @@ 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();
@@ -136,19 +80,13 @@ pub fn watch<R: Runtime>(app_handle: AppHandle<R>) -> Option<SystemAppearanceSta
}
let state = SystemAppearanceState { last_appearance: Arc::new(Mutex::new(last_appearance)) };
#[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;
let thread_state = state.clone();
let _ = std::thread::spawn(move || {
loop {
std::thread::sleep(SYSTEM_APPEARANCE_POLL_INTERVAL);
emit_change(&app_handle, &thread_state);
}
});
Some(state)
}
+70 -465
View File
@@ -1,522 +1,127 @@
// 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
| ImportSource
| 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;
settingHttpVersion: InheritedHttpVersionSetting;
};
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 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;
settingHttpVersion: InheritedHttpVersionSetting;
};
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 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";
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;
};
/**
* 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 HttpVersion = "auto" | "http1" | "http2";
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 ImportSource = {
model: "import_source";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
importer: string;
origin: string;
originLabel: string;
lastImportedAt: string;
};
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 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 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;
settingHttpVersion: HttpVersion;
};
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 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, };
File diff suppressed because one or more lines are too long
+2 -100
View File
@@ -1,102 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type {
Environment,
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from "./gen_models";
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
export type BatchUpsertResult = {
workspaces: Array<Workspace>;
environments: Array<Environment>;
folders: Array<Folder>;
httpRequests: Array<HttpRequest>;
grpcRequests: Array<GrpcRequest>;
websocketRequests: Array<WebsocketRequest>;
};
export type ImportConflictResolution = "keep_mine" | "take_source";
/**
* Where a staged import will be committed.
*
* The destination workspace and optional folder IDs are captured in the plan so the preview describes
* the exact destination that confirmation will use.
*/
export type ImportDestination =
| { type: "new_workspace" }
| { type: "existing_workspace"; workspaceId: string; folderId?: string };
/**
* Where an import's contents came from, used to link the committed workspace back to it.
*/
export type ImportOrigin = {
/**
* The absolute file path or URL the contents were read from.
*/
origin: string;
label: string;
};
export type ImportPlan = {
importer: string;
destination: ImportDestination;
resources: BatchUpsertResult;
warnings: Array<ImportPlanWarning>;
/**
* Stable source key for every model in `resources`, keyed by its planned ID.
*/
sourceKeys: { [key in string]?: string };
/**
* One entry per plannable resource; commit applies only the selected ones.
*/
items: Array<ImportPlanItem>;
origin?: ImportOrigin;
};
export type ImportPlanAction =
| "create"
| "update"
| "delete"
| "unchanged"
| "keep_local"
| "conflict"
| "not_imported";
export type ImportPlanItem = {
action: ImportPlanAction;
model: ImportResourceType;
modelId: string;
name: string;
/**
* Planned parent folder ID for incoming resources; current parent for deletions.
*/
parentId?: string;
selected: boolean;
resolution?: ImportConflictResolution;
reason?: ImportPlanReason;
/**
* Fields where the source and the local copy disagree, so the preview can say why
*/
changedFields: Array<string>;
};
/**
* Extra context for an action that would otherwise be indistinguishable from its plain form.
*/
export type ImportPlanReason = "moved_into_not_imported_folder";
export type ImportPlanWarning = { title: string; detail: string };
/**
* The model types an import plan can contain.
*/
export type ImportResourceType =
| "environment"
| "folder"
| "grpc_request"
| "http_request"
| "websocket_request"
| "workspace";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
+4 -34
View File
@@ -21,10 +21,9 @@ use yaak_git::{
use yaak_grpc::ServiceDefinition;
use yaak_models::models::{
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent,
WorkspaceMeta,
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
};
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
use yaak_models::util::BatchUpsertResult;
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
use yaak_plugins::events::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
@@ -230,7 +229,6 @@ pub struct CmdGetHttpResponseEventsReq {
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportDataReq {
pub file_path: String,
pub destination: ImportDestination,
}
#[derive(Debug, Deserialize, TS)]
@@ -238,31 +236,6 @@ pub struct CmdImportDataReq {
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportUrlReq {
pub url: String,
pub destination: ImportDestination,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdCommitImportReq {
pub plan: ImportPlan,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdListImportSourcesReq {
pub workspace_id: String,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportSourcesForOriginReq {
#[ts(optional)]
pub file_path: Option<String>,
#[ts(optional)]
pub url: Option<String>,
}
#[derive(Debug, Deserialize, TS)]
@@ -936,11 +909,8 @@ macro_rules! with_commands {
cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>,
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
cmd_import_data(CmdImportDataReq) -> ImportPlan,
cmd_import_url(CmdImportUrlReq) -> ImportPlan,
cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult,
cmd_list_import_sources(CmdListImportSourcesReq) -> Vec<ImportSource>,
cmd_import_sources_for_origin(CmdImportSourcesForOriginReq) -> Vec<ImportSource>,
cmd_import_data(CmdImportDataReq) -> BatchUpsertResult,
cmd_import_url(CmdImportUrlReq) -> BatchUpsertResult,
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),
+45 -5
View File
@@ -6,11 +6,14 @@
//! own environment chain before a plugin sees them, or an auth plugin receives
//! `${[ api_key ]}` where it expected a key.
use crate::error::Result;
use crate::error::{Error, Result};
use crate::host::PluginHost;
use crate::render::render_form_values;
use crate::render::render_json_value;
use std::collections::HashMap;
use yaak_models::models::AnyModel;
use yaak_plugins::events::{
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, RenderPurpose,
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, JsonPrimitive,
RenderPurpose,
};
use yaak_rpc_schema::*;
use yaak_templates::RenderOptions;
@@ -28,7 +31,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_form_values(
let values = render_auth_values(
&host,
&req.model,
req.environment_id.as_deref(),
@@ -47,7 +50,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_form_values(
let values = render_auth_values(
&host,
&req.model,
req.environment_id.as_deref(),
@@ -60,3 +63,40 @@ 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)?)
}
+1 -7
View File
@@ -121,12 +121,7 @@ pub trait PluginHost: Host {
/// a render — the variables come from the environment chain, which is an
/// ordinary database read — so handing back the callback keeps the rest of
/// rendering shared instead of pushing whole commands behind this trait.
/// Async so hosts that finish booting their plugin runtime in the
/// background can wait for it here.
fn template_callback(
&self,
purpose: RenderPurpose,
) -> impl Future<Output = crate::Result<impl TemplateCallback>>;
fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback;
/// Every template function the installed plugins expose, for the
/// autocomplete menu.
@@ -135,7 +130,6 @@ 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,
+3 -47
View File
@@ -1,19 +1,12 @@
//! Rendering a template against an environment chain.
//!
//! The variables come from the chain, the functions come from the host's
//! 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.
//! template callback. Neither of these knows which host it is running under —
//! that is the whole point of taking the callback as a parameter.
use crate::error::{Error, Result};
use crate::host::PluginHost;
use serde_json::Value;
use std::collections::HashMap;
use yaak_models::models::{AnyModel, Environment};
use yaak_models::models::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>(
@@ -35,40 +28,3 @@ 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).await?;
let rendered =
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
Ok(serde_json::from_value(rendered)?)
}
+4 -16
View File
@@ -7,7 +7,7 @@
use crate::error::Result;
use crate::host::PluginHost;
use crate::render::{render_form_values, render_template};
use crate::render::render_template;
use yaak_plugins::events::{
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
RenderPurpose,
@@ -21,7 +21,7 @@ pub async fn cmd_render_template<H: PluginHost>(
) -> Result<String> {
let environment_chain =
host.db().resolve_environments(&req.workspace_id, None, req.environment_id.as_deref())?;
let cb = host.template_callback(req.purpose.unwrap_or(RenderPurpose::Preview)).await?;
let cb = host.template_callback(req.purpose.unwrap_or(RenderPurpose::Preview));
let options = RenderOptions {
// A preview that throws would show the user an error where they expect
// to see the value so far, so callers rendering *into the UI* ask for
@@ -41,7 +41,7 @@ pub async fn cmd_template_tokens_to_string<H: PluginHost>(
host: H,
req: CmdTemplateTokensToStringReq,
) -> Result<String> {
let cb = host.template_callback(RenderPurpose::Preview).await?;
let cb = host.template_callback(RenderPurpose::Preview);
Ok(transform_args(req.tokens, &cb)?.to_string())
}
@@ -56,19 +56,7 @@ pub async fn cmd_template_function_config<H: PluginHost>(
host: H,
req: CmdTemplateFunctionConfigReq,
) -> Result<GetTemplateFunctionConfigResponse> {
// 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
host.template_function_config(&req.function_name, req.values, req.model.id()).await
}
pub async fn cmd_get_themes<H: PluginHost>(
+4 -74
View File
@@ -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, cmd_template_function_config};
use yaak_commands::templates::cmd_render_template;
use yaak_commands::{Host, PluginHost};
use yaak_core::WorkspaceContext;
use yaak_crypto::manager::EncryptionManager;
@@ -172,8 +172,6 @@ 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 {
@@ -250,11 +248,8 @@ impl PluginHost for SingleThreadedHost {
Err(yaak_commands::Error::Generic("no plugin runtime on this host".into()))
}
async fn template_callback(
&self,
_purpose: RenderPurpose,
) -> yaak_commands::Result<impl TemplateCallback> {
Ok(NoTemplateFunctions)
fn template_callback(&self, _purpose: RenderPurpose) -> impl TemplateCallback {
NoTemplateFunctions
}
async fn template_function_summaries(
@@ -266,10 +261,9 @@ 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}()")))
}
@@ -382,7 +376,6 @@ 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() };
@@ -455,7 +448,6 @@ 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
@@ -507,65 +499,3 @@ 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"),
);
}
-7
View File
@@ -47,7 +47,6 @@ export type Folder = {
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type GrpcRequest = {
@@ -100,7 +99,6 @@ export type HttpRequest = {
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
@@ -116,12 +114,8 @@ 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 =
@@ -175,5 +169,4 @@ export type Workspace = {
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
settingHttpVersion: HttpVersion;
};
+1 -1
View File
@@ -210,7 +210,7 @@ fn field_to_type_or_ref(root_name: &str, field: FieldDescriptor) -> JsonSchemaEn
// [Protocol Buffers Well-Known Types]: https://protobuf.dev/reference/protobuf/google.protobuf/
"google.protobuf.FieldMask" => JsonSchemaEntry::string(),
"google.protobuf.Timestamp" => JsonSchemaEntry::string_with_format("date-time"),
"google.protobuf.Duration" => JsonSchemaEntry::string_with_format("duration"),
"google.protobuf.Duration" => JsonSchemaEntry::string(),
"google.protobuf.StringValue" => JsonSchemaEntry::string(),
"google.protobuf.BytesValue" => JsonSchemaEntry::string_with_format("byte"),
"google.protobuf.Int32Value" => JsonSchemaEntry::number("int32"),
+4 -24
View File
@@ -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, HttpVersion};
use yaak_models::models::DnsOverride;
use yaak_tls::{
ClientCertificateConfig, NativeClientIdentity, get_tls_config, load_native_client_identity,
};
@@ -39,7 +39,6 @@ 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);
@@ -47,11 +46,7 @@ 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.
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"]),
};
builder.request_alpns(&["h2", "http/1.1"]);
if let Some(identity) = build_native_tls_identity(client_cert)? {
builder.identity(identity);
@@ -105,7 +100,6 @@ 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>,
@@ -134,28 +128,14 @@ 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 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()],
}
let config = get_tls_config(true, true, self.client_certificate.clone())?;
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(), self.http_version)?;
let connector = build_native_tls_connector(self.client_certificate.clone())?;
client = client.use_preconfigured_tls(connector);
}
+1 -4
View File
@@ -28,10 +28,7 @@ impl HttpConnectionManager {
pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result<CachedClient> {
let mut connections = self.connections.write().await;
// 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);
let id = opt.id.clone();
// Clean old connections
connections.retain(|_, (_, last_used)| last_used.elapsed() <= self.ttl);
+1 -46
View File
@@ -12,7 +12,6 @@ export type AnyModel =
| HttpRequest
| HttpResponse
| HttpResponseEvent
| ImportSource
| KeyValue
| Plugin
| Settings
@@ -111,7 +110,6 @@ export type Folder = {
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type GraphQlIntrospection = {
@@ -216,7 +214,6 @@ export type HttpRequest = {
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
@@ -321,7 +318,6 @@ export type HttpSendSettings = {
timeoutMs: number;
sendCookies: boolean;
storeCookies: boolean;
httpVersion: HttpVersion;
};
export type HttpUrlParameter = {
@@ -335,41 +331,8 @@ export type HttpUrlParameter = {
id?: string;
};
export type HttpVersion = "auto" | "http1" | "http2";
export type ImportSource = {
model: "import_source";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
importer: string;
origin: string;
originLabel: string;
lastImportedAt: string;
};
export type ImportSourceResource = {
model: "import_source_resource";
createdAt: string;
updatedAt: string;
importSourceId: string;
sourceKey: string;
modelType: string;
/**
* `None` once the user has decided not to import this key
*/
modelId?: string;
/**
* Hash of the resource as last applied or decided from the source, if one was recorded
*/
contentHash?: string;
};
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
export type InheritedIntSetting = { enabled?: boolean; value: number };
export type KeyValue = {
@@ -512,14 +475,7 @@ 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";
@@ -566,7 +522,6 @@ export type Workspace = {
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
settingHttpVersion: HttpVersion;
};
export type WorkspaceMeta = {
+2 -100
View File
@@ -1,102 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type {
Environment,
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from "./gen_models";
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
export type BatchUpsertResult = {
workspaces: Array<Workspace>;
environments: Array<Environment>;
folders: Array<Folder>;
httpRequests: Array<HttpRequest>;
grpcRequests: Array<GrpcRequest>;
websocketRequests: Array<WebsocketRequest>;
};
export type ImportConflictResolution = "keep_mine" | "take_source";
/**
* Where a staged import will be committed.
*
* The destination workspace and optional folder IDs are captured in the plan so the preview describes
* the exact destination that confirmation will use.
*/
export type ImportDestination =
| { type: "new_workspace" }
| { type: "existing_workspace"; workspaceId: string; folderId?: string };
/**
* Where an import's contents came from, used to link the committed workspace back to it.
*/
export type ImportOrigin = {
/**
* The absolute file path or URL the contents were read from.
*/
origin: string;
label: string;
};
export type ImportPlan = {
importer: string;
destination: ImportDestination;
resources: BatchUpsertResult;
warnings: Array<ImportPlanWarning>;
/**
* Stable source key for every model in `resources`, keyed by its planned ID.
*/
sourceKeys: { [key in string]?: string };
/**
* One entry per plannable resource; commit applies only the selected ones.
*/
items: Array<ImportPlanItem>;
origin?: ImportOrigin;
};
export type ImportPlanAction =
| "create"
| "update"
| "delete"
| "unchanged"
| "keep_local"
| "conflict"
| "not_imported";
export type ImportPlanItem = {
action: ImportPlanAction;
model: ImportResourceType;
modelId: string;
name: string;
/**
* Planned parent folder ID for incoming resources; current parent for deletions.
*/
parentId?: string;
selected: boolean;
resolution?: ImportConflictResolution;
reason?: ImportPlanReason;
/**
* Fields where the source and the local copy disagree, so the preview can say why
*/
changedFields: Array<string>;
};
/**
* Extra context for an action that would otherwise be indistinguishable from its plain form.
*/
export type ImportPlanReason = "moved_into_not_imported_folder";
export type ImportPlanWarning = { title: string; detail: string };
/**
* The model types an import plan can contain.
*/
export type ImportResourceType =
| "environment"
| "folder"
| "grpc_request"
| "http_request"
| "websocket_request"
| "workspace";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
-1
View File
@@ -12,7 +12,6 @@ export function newStoreData(): ModelStoreData {
http_request: {},
http_response: {},
http_response_event: {},
import_source: {},
key_value: {},
plugin: {},
settings: {},
@@ -1,5 +0,0 @@
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,25 +0,0 @@
CREATE TABLE import_sources
(
id TEXT NOT NULL PRIMARY KEY,
model TEXT DEFAULT 'import_source' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
workspace_id TEXT NOT NULL,
importer TEXT NOT NULL,
origin TEXT NOT NULL,
origin_label TEXT NOT NULL,
last_imported_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE TABLE import_source_resources
(
model TEXT DEFAULT 'import_source_resource' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
import_source_id TEXT NOT NULL,
source_key TEXT NOT NULL,
model_type TEXT NOT NULL,
model_id TEXT NOT NULL,
snapshot TEXT NOT NULL,
PRIMARY KEY (import_source_id, source_key)
);
@@ -1,24 +0,0 @@
-- Replace the per-resource snapshot with a content hash, and let a row exist without a model
-- so a resource the user chose not to import can be remembered.
CREATE TABLE import_source_resources_new
(
model TEXT DEFAULT 'import_source_resource' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
import_source_id TEXT NOT NULL,
source_key TEXT NOT NULL,
model_type TEXT NOT NULL,
model_id TEXT,
content_hash TEXT,
PRIMARY KEY (import_source_id, source_key)
);
INSERT INTO import_source_resources_new (model, created_at, updated_at, import_source_id,
source_key, model_type, model_id, content_hash)
SELECT model, created_at, updated_at, import_source_id, source_key, model_type, model_id, NULL
FROM import_source_resources;
DROP TABLE import_source_resources;
ALTER TABLE import_source_resources_new
RENAME TO import_source_resources;
+3 -193
View File
@@ -1,9 +1,9 @@
use crate::error::Result;
use crate::models::HttpRequestIden::{
Authentication, AuthenticationType, Body, BodyType, CreatedAt, Description, FolderId, Headers,
Method, Name, SettingFollowRedirects, SettingHttpVersion, SettingRequestTimeout,
SettingSendCookies, SettingStoreCookies, SettingValidateCertificates, SortPriority, UpdatedAt,
Url, UrlParameters, WorkspaceId,
Method, Name, SettingFollowRedirects, SettingRequestTimeout, SettingSendCookies,
SettingStoreCookies, SettingValidateCertificates, SortPriority, UpdatedAt, Url, UrlParameters,
WorkspaceId,
};
use crate::util::generate_prefixed_id;
use chrono::{NaiveDateTime, Utc};
@@ -143,7 +143,6 @@ 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 {
@@ -155,7 +154,6 @@ 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),
}
}
}
@@ -193,7 +191,6 @@ 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),
]
}
}
@@ -211,8 +208,6 @@ 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 {
@@ -223,7 +218,6 @@ 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,
}
}
}
@@ -261,49 +255,6 @@ 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")]
@@ -533,7 +484,6 @@ impl Default for Workspace {
setting_dns_overrides: Vec::new(),
setting_send_cookies: true,
setting_store_cookies: true,
setting_http_version: HttpVersion::Auto,
}
}
}
@@ -566,7 +516,6 @@ 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 {
@@ -611,7 +560,6 @@ 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()),
])
}
@@ -631,7 +579,6 @@ impl UpsertModelInfo for Workspace {
WorkspaceIden::SettingDnsOverrides,
WorkspaceIden::SettingSendCookies,
WorkspaceIden::SettingStoreCookies,
WorkspaceIden::SettingHttpVersion,
]
}
@@ -642,7 +589,6 @@ 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")?,
@@ -661,7 +607,6 @@ 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(),
})
}
}
@@ -1133,7 +1078,6 @@ impl Default for Folder {
enabled: false,
value: DEFAULT_REQUEST_MESSAGE_SIZE,
},
setting_http_version: InheritedHttpVersionSetting::default(),
}
}
}
@@ -1164,7 +1108,6 @@ 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 {
@@ -1216,7 +1159,6 @@ 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()),
])
}
@@ -1236,7 +1178,6 @@ impl UpsertModelInfo for Folder {
FolderIden::SettingFollowRedirects,
FolderIden::SettingRequestTimeout,
FolderIden::SettingRequestMessageSize,
FolderIden::SettingHttpVersion,
]
}
@@ -1252,7 +1193,6 @@ 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")?,
@@ -1276,7 +1216,6 @@ 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(),
})
}
}
@@ -1344,7 +1283,6 @@ impl Default for HttpRequest {
setting_validate_certificates: InheritedBoolSetting::default(),
setting_follow_redirects: InheritedBoolSetting::default(),
setting_request_timeout: InheritedIntSetting::default(),
setting_http_version: InheritedHttpVersionSetting::default(),
}
}
}
@@ -1381,7 +1319,6 @@ 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 {
@@ -1433,7 +1370,6 @@ 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()),
])
}
@@ -1458,7 +1394,6 @@ impl UpsertModelInfo for HttpRequest {
SettingValidateCertificates,
SettingFollowRedirects,
SettingRequestTimeout,
SettingHttpVersion,
]
}
@@ -1472,7 +1407,6 @@ 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")?,
@@ -1499,7 +1433,6 @@ 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(),
})
}
}
@@ -3022,127 +2955,6 @@ impl<'s> TryFrom<&Row<'s>> for PluginKeyValue {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "import_sources")]
pub struct ImportSource {
#[ts(type = "\"import_source\"")]
pub model: String,
pub id: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub workspace_id: String,
pub importer: String,
pub origin: String,
pub origin_label: String,
pub last_imported_at: NaiveDateTime,
}
impl UpsertModelInfo for ImportSource {
fn table_name() -> impl IntoTableRef + IntoIden {
ImportSourceIden::Table
}
fn id_column() -> impl IntoIden + Eq + Clone {
ImportSourceIden::Id
}
fn generate_id() -> String {
generate_prefixed_id("im")
}
fn order_by() -> (impl IntoColumnRef, Order) {
(ImportSourceIden::CreatedAt, Desc)
}
fn get_id(&self) -> String {
self.id.clone()
}
fn insert_values(
self,
source: &UpdateSource,
) -> DbResult<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {
use ImportSourceIden::*;
Ok(vec![
(CreatedAt, upsert_date(source, self.created_at)),
(UpdatedAt, upsert_date(source, self.updated_at)),
(WorkspaceId, self.workspace_id.into()),
(Importer, self.importer.into()),
(Origin, self.origin.into()),
(OriginLabel, self.origin_label.into()),
(LastImportedAt, self.last_imported_at.into()),
])
}
fn update_columns() -> Vec<impl IntoIden> {
vec![
ImportSourceIden::UpdatedAt,
ImportSourceIden::Importer,
ImportSourceIden::Origin,
ImportSourceIden::OriginLabel,
ImportSourceIden::LastImportedAt,
]
}
fn from_row(row: &Row) -> rusqlite::Result<Self>
where
Self: Sized,
{
Ok(Self {
id: row.get("id")?,
model: row.get("model")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
workspace_id: row.get("workspace_id")?,
importer: row.get("importer")?,
origin: row.get("origin")?,
origin_label: row.get("origin_label")?,
last_imported_at: row.get("last_imported_at")?,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "import_source_resources")]
pub struct ImportSourceResource {
#[ts(type = "\"import_source_resource\"")]
pub model: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub import_source_id: String,
pub source_key: String,
pub model_type: String,
/// `None` once the user has decided not to import this key
#[ts(optional)]
pub model_id: Option<String>,
/// Hash of the resource as last applied or decided from the source, if one was recorded
#[ts(optional)]
pub content_hash: Option<String>,
}
impl<'s> TryFrom<&Row<'s>> for ImportSourceResource {
type Error = rusqlite::Error;
fn try_from(r: &Row<'s>) -> std::result::Result<Self, Self::Error> {
Ok(Self {
model: r.get("model")?,
created_at: r.get("created_at")?,
updated_at: r.get("updated_at")?,
import_source_id: r.get("import_source_id")?,
source_key: r.get("source_key")?,
model_type: r.get("model_type")?,
model_id: r.get("model_id")?,
content_hash: r.get("content_hash")?,
})
}
}
/// Only used as a `from_row` fallback for an unparseable settings column. The
/// value a *new* model gets comes from that model's `Default` impl.
fn default_request_message_size_setting() -> InheritedIntSetting {
@@ -3214,7 +3026,6 @@ define_any_model! {
HttpRequest,
HttpResponse,
HttpResponseEvent,
ImportSource,
KeyValue,
Plugin,
Settings,
@@ -3247,7 +3058,6 @@ impl<'de> Deserialize<'de> for AnyModel {
Some(m) if m == "http_request" => HttpRequest(fv(value).unwrap()),
Some(m) if m == "http_response" => HttpResponse(fv(value).unwrap()),
Some(m) if m == "http_response_event" => HttpResponseEvent(fv(value).unwrap()),
Some(m) if m == "import_source" => ImportSource(fv(value).unwrap()),
Some(m) if m == "key_value" => KeyValue(fv(value).unwrap()),
Some(m) if m == "plugin" => Plugin(fv(value).unwrap()),
Some(m) if m == "settings" => Settings(fv(value).unwrap()),
@@ -208,14 +208,6 @@ 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,14 +153,6 @@ 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
},
})
}
@@ -182,10 +174,7 @@ impl<'a> ClientDb<'a> {
#[cfg(test)]
mod tests {
use crate::init_in_memory;
use crate::models::{
Folder, HttpRequest, HttpRequestHeader, HttpVersion, InheritedHttpVersionSetting, Workspace,
};
use crate::util::UpdateSource;
use crate::models::{HttpRequest, HttpRequestHeader};
#[test]
fn request_resolution_preserves_duplicate_request_headers() {
@@ -221,77 +210,4 @@ 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");
}
}
@@ -1,94 +0,0 @@
use crate::client_db::ClientDb;
use crate::error::Result;
use crate::models::{ImportSourceResource, ImportSourceResourceIden};
use sea_query::ExprTrait;
use sea_query::Keyword::CurrentTimestamp;
use sea_query::{Asterisk, Cond, Expr, OnConflict, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
impl<'a> ClientDb<'a> {
pub fn list_import_source_resources(
&self,
import_source_id: &str,
) -> Result<Vec<ImportSourceResource>> {
let (sql, params) = Query::select()
.from(ImportSourceResourceIden::Table)
.column(Asterisk)
.cond_where(Expr::col(ImportSourceResourceIden::ImportSourceId).eq(import_source_id))
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn().prepare(sql.as_str())?;
let items = stmt.query_map(&*params.as_params(), |row| row.try_into())?;
Ok(items.filter_map(|v| v.ok()).collect())
}
pub fn upsert_import_source_resource(
&self,
resource: &ImportSourceResource,
) -> Result<ImportSourceResource> {
let (sql, params) = Query::insert()
.into_table(ImportSourceResourceIden::Table)
.columns([
ImportSourceResourceIden::CreatedAt,
ImportSourceResourceIden::UpdatedAt,
ImportSourceResourceIden::ImportSourceId,
ImportSourceResourceIden::SourceKey,
ImportSourceResourceIden::ModelType,
ImportSourceResourceIden::ModelId,
ImportSourceResourceIden::ContentHash,
])
.values_panic([
CurrentTimestamp.into(),
CurrentTimestamp.into(),
resource.import_source_id.as_str().into(),
resource.source_key.as_str().into(),
resource.model_type.as_str().into(),
resource.model_id.clone().into(),
resource.content_hash.clone().into(),
])
.on_conflict(
OnConflict::columns([
ImportSourceResourceIden::ImportSourceId,
ImportSourceResourceIden::SourceKey,
])
.update_columns([
ImportSourceResourceIden::UpdatedAt,
ImportSourceResourceIden::ModelType,
ImportSourceResourceIden::ModelId,
ImportSourceResourceIden::ContentHash,
])
.to_owned(),
)
.returning_all()
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn().prepare(sql.as_str())?;
let m = stmt.query_row(&*params.as_params(), |row| row.try_into())?;
Ok(m)
}
pub fn delete_import_source_resource(
&self,
import_source_id: &str,
source_key: &str,
) -> Result<()> {
let (sql, params) = Query::delete()
.from_table(ImportSourceResourceIden::Table)
.cond_where(
Cond::all()
.add(Expr::col(ImportSourceResourceIden::ImportSourceId).eq(import_source_id))
.add(Expr::col(ImportSourceResourceIden::SourceKey).eq(source_key)),
)
.build_rusqlite(SqliteQueryBuilder);
self.conn().execute(sql.as_str(), &*params.as_params())?;
Ok(())
}
pub fn delete_import_source_resources(&self, import_source_id: &str) -> Result<()> {
let (sql, params) = Query::delete()
.from_table(ImportSourceResourceIden::Table)
.cond_where(Expr::col(ImportSourceResourceIden::ImportSourceId).eq(import_source_id))
.build_rusqlite(SqliteQueryBuilder);
self.conn().execute(sql.as_str(), &*params.as_params())?;
Ok(())
}
}
@@ -1,45 +0,0 @@
use crate::client_db::ClientDb;
use crate::error::Result;
use crate::models::{ImportSource, ImportSourceIden};
use crate::util::UpdateSource;
impl<'a> ClientDb<'a> {
pub fn get_import_source(&self, id: &str) -> Result<ImportSource> {
self.find_one(ImportSourceIden::Id, id)
}
pub fn list_import_sources(&self, workspace_id: &str) -> Result<Vec<ImportSource>> {
self.find_many(ImportSourceIden::WorkspaceId, workspace_id, None)
}
pub fn list_import_sources_by_origin(&self, origin: &str) -> Result<Vec<ImportSource>> {
self.find_many(ImportSourceIden::Origin, origin, None)
}
pub fn find_import_source(
&self,
workspace_id: &str,
importer: &str,
origin: &str,
) -> Result<Option<ImportSource>> {
let sources = self.list_import_sources(workspace_id)?;
Ok(sources.into_iter().find(|s| s.importer == importer && s.origin == origin))
}
pub fn upsert_import_source(
&self,
import_source: &ImportSource,
source: &UpdateSource,
) -> Result<ImportSource> {
self.upsert(import_source, source)
}
pub fn delete_import_source(
&self,
import_source: &ImportSource,
source: &UpdateSource,
) -> Result<ImportSource> {
self.delete_import_source_resources(&import_source.id)?;
self.delete(import_source, source)
}
}
-2
View File
@@ -11,8 +11,6 @@ mod grpc_requests;
mod http_requests;
mod http_response_events;
mod http_responses;
mod import_source_resources;
mod import_sources;
mod key_values;
mod model_changes;
mod plugin_key_values;
+4 -13
View File
@@ -6,9 +6,8 @@ use crate::models::{
AnyModel, CookieJar, CookieJarIden, Environment, EnvironmentIden, Folder, FolderIden,
GraphQlIntrospection, GraphQlIntrospectionIden, GrpcConnection, GrpcConnectionIden, GrpcEvent,
GrpcEventIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader, HttpRequestIden,
HttpResponse, HttpResponseEvent, HttpResponseEventIden, HttpResponseIden, ImportSource,
ImportSourceIden, ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden,
WebsocketConnection,
HttpResponse, HttpResponseEvent, HttpResponseEventIden, HttpResponseIden,
ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden, WebsocketConnection,
WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden, WebsocketRequest,
WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta, WorkspaceMetaIden,
};
@@ -86,10 +85,6 @@ impl<'a> ClientDb<'a> {
self.delete_many_untracked::<Folder>(FolderIden::WorkspaceId, wid)?;
self.delete_many_untracked::<Environment>(EnvironmentIden::WorkspaceId, wid)?;
self.delete_many_untracked::<CookieJar>(CookieJarIden::WorkspaceId, wid)?;
for import_source in self.list_import_sources(wid)? {
self.delete_import_source_resources(&import_source.id)?;
}
self.delete_many_untracked::<ImportSource>(ImportSourceIden::WorkspaceId, wid)?;
self.delete_many_untracked::<SyncState>(SyncStateIden::WorkspaceId, wid)?;
self.delete_many_untracked::<WorkspaceMeta>(WorkspaceMetaIden::WorkspaceId, wid)?;
self.delete(workspace, source)
@@ -101,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);
}
};
@@ -182,10 +177,6 @@ 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()),
),
}
}
}
-146
View File
@@ -85,152 +85,6 @@ pub struct BatchUpsertResult {
pub websocket_requests: Vec<WebsocketRequest>,
}
/// Where a staged import will be committed.
///
/// The destination workspace and optional folder IDs are captured in the plan so the preview describes
/// the exact destination that confirmation will use.
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportDestination {
NewWorkspace,
ExistingWorkspace {
#[serde(rename = "workspaceId")]
workspace_id: String,
#[serde(rename = "folderId")]
#[ts(optional)]
folder_id: Option<String>,
},
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlanWarning {
pub title: String,
pub detail: String,
}
/// Where an import's contents came from, used to link the committed workspace back to it.
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportOrigin {
/// The absolute file path or URL the contents were read from.
pub origin: String,
pub label: String,
}
/// The model types an import plan can contain.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportResourceType {
Environment,
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
}
impl ImportResourceType {
pub fn as_str(&self) -> &'static str {
match self {
ImportResourceType::Environment => "environment",
ImportResourceType::Folder => "folder",
ImportResourceType::GrpcRequest => "grpc_request",
ImportResourceType::HttpRequest => "http_request",
ImportResourceType::WebsocketRequest => "websocket_request",
ImportResourceType::Workspace => "workspace",
}
}
pub fn from_str(value: &str) -> Option<Self> {
match value {
"environment" => Some(ImportResourceType::Environment),
"folder" => Some(ImportResourceType::Folder),
"grpc_request" => Some(ImportResourceType::GrpcRequest),
"http_request" => Some(ImportResourceType::HttpRequest),
"websocket_request" => Some(ImportResourceType::WebsocketRequest),
"workspace" => Some(ImportResourceType::Workspace),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportPlanAction {
Create,
Update,
Delete,
Unchanged,
KeepLocal,
Conflict,
/// Present in the source but previously turned down; selecting it imports it again
NotImported,
}
/// Extra context for an action that would otherwise be indistinguishable from its plain form.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportPlanReason {
MovedIntoNotImportedFolder,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportConflictResolution {
KeepMine,
TakeSource,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlanItem {
pub action: ImportPlanAction,
pub model: ImportResourceType,
pub model_id: String,
pub name: String,
/// Planned parent folder ID for incoming resources; current parent for deletions.
#[ts(optional)]
pub parent_id: Option<String>,
pub selected: bool,
#[ts(optional)]
pub resolution: Option<ImportConflictResolution>,
#[ts(optional)]
pub reason: Option<ImportPlanReason>,
/// Fields where the source and the local copy disagree, so the preview can say why
#[serde(default)]
pub changed_fields: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlan {
pub importer: String,
pub destination: ImportDestination,
pub resources: BatchUpsertResult,
pub warnings: Vec<ImportPlanWarning>,
/// Stable source key for every model in `resources`, keyed by its planned ID.
pub source_keys: BTreeMap<String, String>,
/// One entry per plannable resource; commit applies only the selected ones.
#[serde(default)]
pub items: Vec<ImportPlanItem>,
#[serde(default)]
#[ts(optional)]
pub origin: Option<ImportOrigin>,
}
pub fn get_workspace_export_resources(
db: &ClientDb,
yaak_version: &str,
+1 -12
View File
@@ -474,18 +474,7 @@ export type ImportRequest = { content: string, };
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type ImportResponse = {
/**
* Display name of the importer that recognized the input.
*/
importer: string, resources: ImportResources,
/**
* Identifies the same source element across re-parses, keyed by the IDs in `resources`.
*
* Must come from the document, never from anything the user can rename in Yaak. Only set
* for formats that carry their own identifiers; the host derives the rest.
*/
sourceKeys?: { [key in string]?: string }, };
export type ImportResponse = { resources: ImportResources, };
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
+1 -29
View File
@@ -11,7 +11,6 @@ export type AnyModel =
| HttpRequest
| HttpResponse
| HttpResponseEvent
| ImportSource
| KeyValue
| Plugin
| Settings
@@ -110,7 +109,6 @@ export type Folder = {
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type GraphQlIntrospection = {
@@ -215,7 +213,6 @@ export type HttpRequest = {
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
@@ -317,24 +314,8 @@ export type HttpUrlParameter = {
id?: string;
};
export type HttpVersion = "auto" | "http1" | "http2";
export type ImportSource = {
model: "import_source";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
importer: string;
origin: string;
originLabel: string;
lastImportedAt: string;
};
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
export type InheritedIntSetting = { enabled?: boolean; value: number };
export type KeyValue = {
@@ -397,7 +378,6 @@ export type Settings = {
themeLight: string;
updateChannel: string;
hideLicenseBadge: boolean;
promptFeedback: boolean;
autoupdate: boolean;
autoDownloadUpdates: boolean;
checkNotifications: boolean;
@@ -448,14 +428,7 @@ 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";
@@ -500,7 +473,6 @@ export type Workspace = {
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
settingHttpVersion: HttpVersion;
};
export type WorkspaceMeta = {
+1 -10
View File
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::collections::HashMap;
use ts_rs::TS;
use yaak_models::models::{
AnyModel, Environment, Folder, GrpcRequest, HttpRequest, HttpResponse, WebsocketRequest,
@@ -247,16 +247,7 @@ pub struct ImportRequest {
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct ImportResponse {
/// Display name of the importer that recognized the input.
pub importer: String,
pub resources: ImportResources,
/// Identifies the same source element across re-parses, keyed by the IDs in `resources`.
///
/// Must come from the document, never from anything the user can rename in Yaak. Only set
/// for formats that carry their own identifiers; the host derives the rest.
#[ts(optional)]
pub source_keys: Option<BTreeMap<String, String>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
+19 -31
View File
@@ -187,25 +187,24 @@ impl PluginManager {
}
let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?;
// Scope the db connection so the future stays Send across the await below
let plugins = {
let db = query_manager.connect();
for dir in &bundled_dirs {
if db.get_plugin_by_directory(dir).is_none() {
db.upsert_plugin(
&Plugin {
directory: dir.clone(),
enabled: true,
url: None,
source: PluginSource::Bundled,
..Default::default()
},
&UpdateSource::Background,
)?;
}
let db = query_manager.connect();
for dir in &bundled_dirs {
if db.get_plugin_by_directory(dir).is_none() {
db.upsert_plugin(
&Plugin {
directory: dir.clone(),
enabled: true,
url: None,
source: PluginSource::Bundled,
..Default::default()
},
&UpdateSource::Background,
)?;
}
db.list_plugins()?
};
}
let plugins = db.list_plugins()?;
drop(db);
let init_errors = plugin_manager.initialize_all_plugins(plugins, plugin_context).await;
if !init_errors.is_empty() {
@@ -1105,19 +1104,8 @@ impl PluginManager {
.await?;
// TODO: Don't just return the first valid response
let result = reply_events.into_iter().find_map(|e| match e {
InternalEvent {
plugin_name,
payload: InternalEventPayload::ImportResponse(mut resp),
..
} => {
// Older plugin runtimes do not include the importer's display name. The plugin
// package name is still enough to identify the detected format in that case.
if resp.importer.is_empty() {
resp.importer = plugin_name;
}
Some(resp)
}
let result = reply_events.into_iter().find_map(|e| match e.payload {
InternalEventPayload::ImportResponse(resp) => Some(resp),
_ => None,
});
-7
View File
@@ -47,7 +47,6 @@ export type Folder = {
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type GrpcRequest = {
@@ -100,7 +99,6 @@ export type HttpRequest = {
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
@@ -116,12 +114,8 @@ 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 =
@@ -188,5 +182,4 @@ export type Workspace = {
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
settingHttpVersion: HttpVersion;
};
-9
View File
@@ -209,7 +209,6 @@ impl TryFrom<AnyModel> for SyncModel {
AnyModel::GrpcEvent(m) => return Err(UnknownModel(m.model)),
AnyModel::HttpResponse(m) => return Err(UnknownModel(m.model)),
AnyModel::HttpResponseEvent(m) => return Err(UnknownModel(m.model)),
AnyModel::ImportSource(m) => return Err(UnknownModel(m.model)),
AnyModel::KeyValue(m) => return Err(UnknownModel(m.model)),
AnyModel::Plugin(m) => return Err(UnknownModel(m.model)),
AnyModel::Settings(m) => return Err(UnknownModel(m.model)),
@@ -227,14 +226,6 @@ mod migration_tests {
use crate::error::Result;
use crate::models::SyncModel;
#[test]
fn import_sources_are_excluded_from_sync() {
let model = yaak_models::models::AnyModel::ImportSource(
yaak_models::models::ImportSource::default(),
);
assert!(SyncModel::try_from(model).is_err());
}
#[test]
fn deserializes_environment_via_syncmodel_with_fixups() -> Result<()> {
let raw = r#"
+14 -1
View File
@@ -8,12 +8,25 @@ 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>> + Send;
) -> impl Future<Output = Result<String>> + MaybeSend;
fn transform_arg(&self, fn_name: &str, arg_name: &str, arg_value: &str) -> Result<String>;
}
+1
View File
@@ -25,6 +25,7 @@ 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
+9 -1
View File
@@ -3,4 +3,12 @@
// 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, rpc } from "./pkg";
export {
blob_delete,
blob_get,
blob_put,
boot,
prepare_http_send,
render_template,
rpc,
} from "./pkg";
+16 -7
View File
@@ -26,15 +26,24 @@ 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 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.
* 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.
*
* 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.
* `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.
*/
export function prepare_http_send(payload: any): Promise<any>;
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>;
/**
* Run one command as `label` (the calling tab's identity, which stands in for
+1 -1
View File
@@ -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, rpc
blob_delete, blob_get, blob_put, boot, prepare_http_send, render_template, rpc
} from "./yaak_wasm_bg.js";
+39 -15
View File
@@ -63,18 +63,34 @@ export function boot() {
}
/**
* 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.
* 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.
*
* 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.
* `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.
* @param {any} payload
* @param {any} plugins
* @returns {Promise<any>}
*/
export function prepare_http_send(payload) {
const ret = wasm.prepare_http_send(payload);
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);
return ret;
}
@@ -225,6 +241,10 @@ 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;
@@ -669,6 +689,10 @@ 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;
@@ -697,22 +721,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: 1117, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
// 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`.
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: 212, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 229, 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: 83, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf);
// 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);
return ret;
}
export function __wbindgen_cast_0000000000000004(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 210, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 227, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f);
return ret;
}
@@ -765,8 +789,8 @@ function wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3(arg0, arg
}
}
function wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__he166673e9c1b4e95(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__he166673e9c1b4e95(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
Binary file not shown.
+3 -2
View File
@@ -5,7 +5,8 @@ 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) => any;
export const prepare_http_send: (a: any, b: any) => any;
export const render_template: (a: any, b: 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;
@@ -18,7 +19,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__h4381d8e749fe46cf: (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__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;
+138 -37
View File
@@ -232,6 +232,14 @@ 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 {
@@ -415,6 +423,34 @@ 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"))),
}
}
@@ -439,6 +475,9 @@ 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.
@@ -447,22 +486,44 @@ struct PreparedHttpSend {
cookie_jar: Option<CookieJar>,
}
/// 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;
/// 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>,
}
impl TemplateCallback for NoPluginsCallback {
impl TemplateCallback for JsTemplateCallback {
fn run(
&self,
fn_name: &str,
_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)) }
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"))
})
}
}
fn transform_arg(
@@ -475,19 +536,35 @@ impl TemplateCallback for NoPluginsCallback {
}
}
/// 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.
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.
///
/// 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.
/// `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.
#[wasm_bindgen]
pub async fn prepare_http_send(payload: JsValue) -> Result<JsValue> {
pub async fn prepare_http_send(payload: JsValue, plugins: 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) = with_host(|host| {
let (request, environment_chain, settings, cookie_jar, auth_context_id) = 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
@@ -497,7 +574,7 @@ pub async fn prepare_http_send(payload: JsValue) -> Result<JsValue> {
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)?;
@@ -506,34 +583,21 @@ pub async fn prepare_http_send(payload: JsValue) -> Result<JsValue> {
None => None,
};
let request = HttpRequest { authentication_type, authentication, headers, ..request };
Ok((request, environment_chain, settings, cookie_jar))
Ok((request, environment_chain, settings, cookie_jar, auth_context_id))
})?;
let rendered = render_http_request(
&request,
environment_chain,
&NoPluginsCallback,
&template_callback(plugins),
&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,
@@ -544,6 +608,43 @@ pub async fn prepare_http_send(payload: JsValue) -> Result<JsValue> {
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 */
/* -------------------------------------------------------------------------- */
-3
View File
@@ -9,8 +9,6 @@ async-trait = "0.1"
base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport
log = { workspace = true }
md5 = "0.8.0"
sha2 = { workspace = true }
chrono = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync", "rt"] }
@@ -23,6 +21,5 @@ yaak-templates = { workspace = true }
yaak-tls = { workspace = true }
[dev-dependencies]
rusqlite = { version = "0.38", features = ["bundled"] }
tempfile = "3"
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+87 -2722
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -190,7 +190,6 @@ 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(),
+40
View File
@@ -16,6 +16,7 @@
"packages/platform",
"packages/plugin-runtime",
"packages/plugin-runtime-types",
"packages/plugin-sandbox",
"plugins-external/mcp-server",
"plugins-external/faker",
"plugins-external/httpsnippet",
@@ -1470,6 +1471,21 @@
"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",
@@ -5638,6 +5654,10 @@
"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
@@ -12799,6 +12819,15 @@
],
"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",
@@ -15859,6 +15888,17 @@
"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"
+2 -1
View File
@@ -15,6 +15,7 @@
"packages/platform",
"packages/plugin-runtime",
"packages/plugin-runtime-types",
"packages/plugin-sandbox",
"plugins-external/mcp-server",
"plugins-external/faker",
"plugins-external/httpsnippet",
@@ -100,7 +101,7 @@
"lint:vp": "vp lint",
"lint:workspaces": "npm run --workspaces --if-present lint",
"replace-version": "node scripts/replace-version.cjs",
"format": "vp fmt",
"format": "vp fmt --ignore-path .oxfmtignore",
"tauri": "tauri",
"client:tauri-before-build": "npm run bootstrap",
"client:tauri-before-dev": "node scripts/run-workspaces-dev.mjs apps/yaak-client",
+2
View File
@@ -2,3 +2,5 @@ export * from "./debounce";
export * from "./eagerDebounceAsync";
export * from "./formatSize";
export * from "./templateFunction";
export * from "./pluginForms";
export * from "./responseBody";
+403
View File
@@ -0,0 +1,403 @@
/**
* `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;
}
@@ -4,10 +4,12 @@ import type {
DynamicAuthenticationArg,
DynamicPromptFormArg,
DynamicTemplateFunctionArg,
TemplateFunctionPlugin,
} from "@yaakapp/api";
import type {
CallHttpAuthenticationActionArgs,
CallTemplateFunctionArgs,
FormInput,
} from "@yaakapp-internal/plugins";
type AnyDynamicArg = DynamicTemplateFunctionArg | DynamicAuthenticationArg | DynamicPromptFormArg;
@@ -73,3 +75,33 @@ 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 };
}
+112 -43
View File
@@ -17,15 +17,22 @@
* 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) => Promise<unknown>;
type Handler = (
payload: RpcPayload,
db: WorkerConnection,
plugins: WebPlugins,
) => Promise<unknown>;
/** Placeholder shown wherever the desktop would show a real filesystem path. */
const NO_PATH = "";
@@ -41,6 +48,31 @@ 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.
*
@@ -73,10 +105,16 @@ 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) => {
cmd_send_http_request: (payload, db, plugins) => {
const requestId = str(payload, "requestId");
if (requestId == null) throw new Error("cmd_send_http_request needs a requestId");
return sendHttpRequest(db, requestId, str(payload, "environmentId"), str(payload, "cookieJarId"));
return sendHttpRequest(
db,
plugins,
requestId,
str(payload, "environmentId"),
str(payload, "cookieJarId"),
);
},
/* -------------------------------- app ---------------------------------- */
@@ -146,22 +184,67 @@ 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.
*
* 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.
* 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.
*/
async cmd_get_http_authentication_summaries() {
return HTTP_AUTHENTICATION_SUMMARIES;
async cmd_get_http_authentication_summaries(_payload, _db, plugins) {
return plugins.httpAuthenticationSummaries();
},
async cmd_template_function_summaries() {
return [{ pluginRefId: "web", functions: [] }];
async cmd_template_function_summaries(_payload, _db, plugins) {
return plugins.templateFunctionSummaries();
},
async cmd_get_http_authentication_config() {
return { args: [], pluginRefId: "web" };
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_format_json(payload) {
@@ -176,13 +259,19 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
},
/**
* 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.
* 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.
*/
async cmd_render_template(payload) {
return text(payload, "template");
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,
});
},
/* ------------------------------- bodies -------------------------------- */
@@ -224,21 +313,6 @@ 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.
@@ -253,7 +327,6 @@ 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"],
@@ -267,9 +340,6 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
// Anything that needs files the page can't reach.
cmd_import_data: ["Importing from a file needs a filesystem, which a browser tab has no", "localFiles"],
cmd_import_url: ["Importing from a URL needs the Yaak server, which isn't available yet", null],
cmd_commit_import: ["Importing needs a plugin, which this host doesn't run", null],
cmd_list_import_sources: ["Importing isn't available in the browser yet", null],
cmd_import_sources_for_origin: ["Importing isn't available in the browser yet", null],
cmd_export_data: ["Exporting to a file isn't available in the browser yet", "localFiles"],
cmd_save_response: ["Saving a response to disk isn't available in the browser", "localFiles"],
cmd_save_base64_to_binary: ["Saving to disk isn't available in the browser", "localFiles"],
@@ -298,14 +368,12 @@ 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],
};
@@ -332,9 +400,10 @@ 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);
if (handler != null) return handler(payload, db, plugins);
const declined = DECLINED[cmd as AppCmd];
if (declined != null) throw unsupported(cmd, declined[0], declined[1]);
+39
View File
@@ -50,6 +50,9 @@ 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
@@ -147,6 +150,9 @@ 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;
}
}
@@ -159,6 +165,34 @@ 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 }));
}
@@ -168,6 +202,11 @@ 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);
+10 -3
View File
@@ -28,6 +28,7 @@ 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. */
@@ -60,7 +61,9 @@ 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: 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,
encryption: false,
updater: false,
// Reading needs a permission prompt at first paint, which is a bad ask for
@@ -160,8 +163,12 @@ 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.
@@ -231,7 +238,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) as Promise<T>;
return runCommand(cmd, payload ?? {}, db, plugins) as Promise<T>;
},
async rpcStream<T, M>(
@@ -244,7 +251,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)) as T;
const result = (await runCommand(cmd, { ...payload, streamId }, db, plugins)) as T;
return { result, unlisten };
} catch (err) {
unlisten();
+298
View File
@@ -0,0 +1,298 @@
/**
* 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),
});
}
}
}
+12 -1
View File
@@ -16,6 +16,15 @@ 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 }
@@ -38,7 +47,9 @@ 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 };
| { 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 };
/** 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
+56 -4
View File
@@ -33,7 +33,8 @@ import type {
} from "@yaakapp-internal/models";
import type { Frame, SendRequest } from "@yaakapp-internal/web";
import type { WorkerConnection } from "./connection";
import { serverIdentity, serverSendUrl, readFrames } from "./server";
import type { WebPlugins } from "./plugins";
import { readFrames, serverIdentity, serverSendUrl } from "./server";
/* -------------------------------- shapes --------------------------------- */
@@ -51,11 +52,59 @@ 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;
@@ -63,6 +112,7 @@ const PROGRESS_INTERVAL_MS = 100;
export async function sendHttpRequest(
db: WorkerConnection,
plugins: WebPlugins,
requestId: string,
environmentId: string | null,
cookieJarId: string | null,
@@ -78,7 +128,7 @@ export async function sendHttpRequest(
const unlistenCancel = db.listen(`cancel_http_response_${response.id}`, () => cancel.abort());
try {
await runSend(db, response, requestId, environmentId, cookieJarId, cancel.signal);
await runSend(db, plugins, response, requestId, environmentId, cookieJarId, cancel.signal);
} catch (err) {
const message = cancel.signal.aborted ? "Request canceled" : errorMessage(err);
await response.finish({ error: message });
@@ -90,6 +140,7 @@ export async function sendHttpRequest(
async function runSend(
db: WorkerConnection,
plugins: WebPlugins,
response: ResponseWriter,
requestId: string,
environmentId: string | null,
@@ -101,7 +152,8 @@ async function runSend(
environmentId,
cookieJarId,
});
await response.patch({ url: prepared.request.url });
const request = await applyAuthentication(plugins, prepared);
await response.patch({ url: 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
@@ -111,7 +163,7 @@ async function runSend(
timeline.push(prepared.settingEvents);
const body: SendRequest = {
request: prepared.request,
request,
settings: prepared.settings,
cookies: prepared.cookieJar?.cookies ?? null,
};

Some files were not shown because too many files have changed in this diff Show More