Compare commits

..
41 changed files with 686 additions and 4398 deletions
Generated
+4 -109
View File
@@ -613,15 +613,11 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"axum-macros",
"base64 0.22.1",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
@@ -630,17 +626,10 @@ dependencies = [
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite 0.24.0",
"tower 0.5.2",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -661,18 +650,6 @@ dependencies = [
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-macros"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
@@ -3256,12 +3233,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -9109,18 +9080,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log 0.4.29",
"tokio",
"tungstenite 0.24.0",
]
[[package]]
name = "tokio-tungstenite"
version = "0.26.2"
@@ -9134,7 +9093,7 @@ dependencies = [
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite 0.26.2",
"tungstenite",
]
[[package]]
@@ -9328,7 +9287,6 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -9339,23 +9297,13 @@ checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
dependencies = [
"bitflags 2.11.0",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower 0.5.2",
"tower-layer",
"tower-service",
"tracing",
"url",
]
@@ -9377,7 +9325,6 @@ version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
"log 0.4.29",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -9524,24 +9471,6 @@ dependencies = [
"termcolor",
]
[[package]]
name = "tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log 0.4.29",
"rand 0.8.5",
"sha1",
"thiserror 1.0.69",
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.26.2"
@@ -11087,7 +11016,7 @@ dependencies = [
"thiserror 2.0.17",
"tokio",
"tokio-stream",
"tokio-tungstenite 0.26.2",
"tokio-tungstenite",
"tokio-util",
"ts-rs",
"url",
@@ -11388,7 +11317,7 @@ dependencies = [
"sha2",
"thiserror 2.0.17",
"tokio",
"tokio-tungstenite 0.26.2",
"tokio-tungstenite",
"ts-rs",
"yaak-common",
"yaak-crypto",
@@ -11443,40 +11372,6 @@ dependencies = [
"ts-rs",
]
[[package]]
name = "yaak-server"
version = "0.1.0"
dependencies = [
"axum",
"charset",
"chrono",
"clap",
"dirs",
"env_logger",
"eventsource-client",
"futures",
"include_dir",
"log 0.4.29",
"mime_guess",
"pretty_graphql",
"rand 0.8.5",
"serde",
"serde_json",
"serde_urlencoded",
"tokio",
"tower-http",
"yaak",
"yaak-common",
"yaak-core",
"yaak-crypto",
"yaak-http",
"yaak-models",
"yaak-plugins",
"yaak-rpc",
"yaak-sse",
"yaak-templates",
]
[[package]]
name = "yaak-sse"
version = "0.1.0"
@@ -11577,7 +11472,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.17",
"tokio",
"tokio-tungstenite 0.26.2",
"tokio-tungstenite",
"url",
"yaak-http",
"yaak-models",
-2
View File
@@ -25,8 +25,6 @@ members = [
"crates-proxy/yaak-proxy-lib",
# CLI crates
"crates-cli/yaak-cli",
# Headless server crates
"crates-server/yaak-server",
# Tauri-specific crates
"crates-tauri/yaak-app-client",
"crates-tauri/yaak-app-proxy",
@@ -8,7 +8,6 @@ import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl";
import { useResponseViewMode } from "../hooks/useResponseViewMode";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
@@ -410,13 +409,14 @@ function EnsureCompleteResponse({
Component,
}: {
response: HttpResponse;
Component: ComponentType<{ url: string }>;
Component: ComponentType<{ bodyPath: string }>;
}) {
// Wait until the response has been fully-downloaded before asking for it
const complete = response.state === "closed";
const bodyUrl = useResponseBodyUrl(complete ? response : null);
if (response.bodyPath === null) {
return <div>Empty response body</div>;
}
if (!complete || bodyUrl.isPending) {
// Wait until the response has been fully-downloaded
if (response.state !== "closed") {
return (
<EmptyStateText>
<LoadingIcon />
@@ -424,15 +424,7 @@ function EnsureCompleteResponse({
);
}
if (bodyUrl.error) {
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
}
if (bodyUrl.data == null) {
return <div>Empty response body</div>;
}
return <Component url={bodyUrl.data} />;
return <Component bodyPath={response.bodyPath} />;
}
function HttpSvgViewer({ response }: { response: HttpResponse }) {
+81 -13
View File
@@ -36,6 +36,7 @@ import { fireAndForget } from "../../lib/fireAndForget";
import { ErrorBoundary } from "../ErrorBoundary";
import { Button } from "./Button";
import { Hotkey } from "./Hotkey";
import { IconButton } from "./IconButton";
import { Separator } from "./Separator";
export type DropdownItemSeparator = {
@@ -66,6 +67,12 @@ export type DropdownItemDefault = {
submenu?: DropdownItem[];
/** If true, submenu opens on click instead of hover */
submenuOpenOnClick?: boolean;
/**
* How the submenu opens. "row" (default) opens it from the row itself (hover, or click
* with submenuOpenOnClick). "button" keeps the row selectable via onSelect and renders
* a dedicated button on the right that opens the submenu.
*/
submenuTrigger?: "row" | "button";
icon?: IconProps["icon"];
};
@@ -502,9 +509,15 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
}
}
if (!item.keepOpenOnSelect) handleCloseAll();
if (!item.keepOpenOnSelect) {
handleCloseAll();
} else if (isSubmenu) {
// Keep the parent menu open, but close this submenu — its items may no
// longer describe the row after the action (e.g. Pin → Unpin, Remove)
handleClose();
}
},
[handleCloseAll, setSelectedIndex],
[handleCloseAll, handleClose, isSubmenu, setSelectedIndex],
);
useImperativeHandle(ref, () => {
@@ -629,7 +642,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
const item = filteredItems[selectedIndex ?? -1];
if (!item || item.type === "separator" || item.type === "content") return;
e.preventDefault();
if (item.submenu) {
if (item.submenu && item.submenuTrigger !== "button") {
const parent = document.activeElement as HTMLButtonElement;
if (parent) {
setActiveSubmenu({ item, parent, viaKeyboard: true });
@@ -648,9 +661,11 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
clearTimeout(submenuTimeoutRef.current);
}
if (item.submenu && !item.submenuOpenOnClick) {
if (item.submenu && !item.submenuOpenOnClick && item.submenuTrigger !== "button") {
setActiveSubmenu({ item, parent });
} else if (activeSubmenu) {
} else if (activeSubmenu && activeSubmenu.item !== item) {
// Hovering the row that owns the open submenu must not dismiss it — the
// pointer travels across the row on its way to a button-triggered submenu
submenuTimeoutRef.current = window.setTimeout(() => {
const submenuEl = submenuRef.current;
if (!submenuEl || !activeSubmenu) {
@@ -797,6 +812,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
onFocus={handleFocus}
onSelect={handleSelect}
onHover={handleItemHover}
onOpenSubmenu={(item, el) => setActiveSubmenu({ item, parent: el })}
// oxlint-disable-next-line no-array-index-key -- It's fine
key={i}
item={item}
@@ -868,6 +884,7 @@ interface MenuItemProps {
onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise<void>;
onFocus: (item: DropdownItemDefault) => void;
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
onOpenSubmenu: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
focused: boolean;
isParentOfActiveSubmenu?: boolean;
}
@@ -879,6 +896,7 @@ function MenuItem({
onHover,
item,
onSelect,
onOpenSubmenu,
isParentOfActiveSubmenu,
...props
}: MenuItemProps) {
@@ -914,19 +932,22 @@ function MenuItem({
e.currentTarget.focus();
};
const rightSlot = item.submenu ? (
<Icon icon="chevron_right" color="secondary" />
) : (
(item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
);
const hasButtonSubmenu = item.submenu != null && item.submenuTrigger === "button";
return (
const rightSlot =
item.submenu && !hasButtonSubmenu ? (
<Icon icon="chevron_right" color="secondary" />
) : (
(item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
);
const button = (
<Button
ref={initRef}
size="sm"
tabIndex={-1}
onMouseEnter={handleMouseEnter}
onMouseLeave={(e) => e.currentTarget.blur()}
onMouseEnter={hasButtonSubmenu ? undefined : handleMouseEnter}
onMouseLeave={hasButtonSubmenu ? undefined : (e) => e.currentTarget.blur()}
disabled={item.disabled}
onFocus={handleFocus}
onClick={handleClick}
@@ -947,6 +968,7 @@ function MenuItem({
"min-w-32 outline-hidden px-2 mx-1.5 flex whitespace-nowrap",
"focus:bg-surface-highlight focus:text rounded-sm focus:outline-hidden focus-visible:outline-1",
isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm",
hasButtonSubmenu && "pr-8",
item.color === "danger" && "text-danger!",
item.color === "primary" && "text-primary!",
item.color === "success" && "text-success!",
@@ -959,6 +981,52 @@ function MenuItem({
<div className={classNames("truncate min-w-20")}>{item.label}</div>
</Button>
);
if (!hasButtonSubmenu) {
return button;
}
// The submenu trigger overlays the row as a sibling (not a child) because the row is
// itself a button and buttons cannot nest. Hover handling lives on this wrapper so the
// row keeps its focus highlight while the mouse is over the trigger.
return (
<div
className="relative grid group/menuitem"
onMouseEnter={() => {
const el = buttonRef.current;
if (el == null) return;
onHover(item, el);
el.focus();
}}
onMouseLeave={() => buttonRef.current?.blur()}
>
{button}
<div
className={classNames(
"absolute right-1.5 inset-y-0 flex items-center",
"opacity-0 group-hover/menuitem:opacity-100 group-focus-within/menuitem:opacity-100",
)}
>
<IconButton
color="custom"
size="2xs"
tabIndex={-1}
icon="ellipsis_vertical"
iconColor="secondary"
title="More actions"
className="h-full! w-7!"
onMouseDown={(e) => {
// Prevent the trigger from stealing focus, which would unhighlight the row
e.preventDefault();
}}
onClick={(e) => {
e.stopPropagation();
onOpenSubmenu(item, e.currentTarget);
}}
/>
</div>
</div>
);
}
interface MenuItemHotKeyProps {
@@ -1,29 +1,29 @@
import { useEffect, useState } from "react";
import { platform } from "@yaakapp-internal/platform";
interface Props {
/** A URL the host resolved, for a body it already stored. */
url?: string;
bodyPath?: string;
data?: Uint8Array;
mimeType?: string;
}
export function AudioViewer({ url, data, mimeType }: Props) {
export function AudioViewer({ bodyPath, data, mimeType }: Props) {
const [src, setSrc] = useState<string>();
useEffect(() => {
if (url) {
setSrc(url);
if (bodyPath) {
setSrc(platform.files.url(bodyPath));
} else if (data) {
// The type matters here in a way it doesn't for an image: a media element goes by what
// the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" });
const objectUrl = URL.createObjectURL(blob);
setSrc(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
} else {
setSrc(undefined);
}
}, [url, data, mimeType]);
}, [bodyPath, data, mimeType]);
// oxlint-disable-next-line jsx-a11y/media-has-caption
return <audio className="w-full" controls src={src} />;
@@ -1,7 +1,9 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
import { useResponseBodyText } from "../../hooks/useResponseBodyText";
import { responseBodyTextQuery, useResponseBodyText } from "../../hooks/useResponseBodyText";
import { useResponseFilter } from "../../hooks/useResponseFilter";
import { useSaveResponse } from "../../hooks/useSaveResponse";
import { languageFromContentType } from "../../lib/contentType";
import { getContentTypeFromHeaders } from "../../lib/model_util";
@@ -52,30 +54,25 @@ interface HttpTextViewerProps {
}
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
const [currentFilter, setCurrentFilter] = useState<string | null>(null);
const filteredBody = useResponseBodyText({ response, filter: currentFilter });
const queryClient = useQueryClient();
const filter = useResponseFilter({
stateKey: `response.body.${response.requestId}`,
// Shares the display query's cache entry, so the verdict costs no extra RPC
runFilter: useCallback(
(f: string) => queryClient.fetchQuery(responseBodyTextQuery({ response, filter: f })),
[queryClient, response],
),
});
const filteredBody = useResponseBodyText({ response, filter: filter.appliedFilter });
const saveResponse = useSaveResponse(response);
const copyResponse = useCopyHttpResponse(response);
const actionsDisabled = response.state !== "closed" && response.status >= 100;
const filterCallback = useMemo(
() => (filter: string) => {
setCurrentFilter(filter);
return {
data: filteredBody.data,
isPending: filteredBody.isPending,
error: !!filteredBody.error,
};
},
[filteredBody],
);
return (
<TextViewer
text={text}
language={language}
stateKey={`response.body.${response.id}`}
filterStateKey={`response.body.${response.requestId}`}
pretty={pretty}
className={className}
footerActions={[
@@ -98,7 +95,12 @@ function HttpTextViewer({ response, text, language, pretty, className }: HttpTex
className="border !border-border-subtle"
/>,
]}
onFilter={filterCallback}
filter={filter}
filterResult={{
data: filteredBody.data,
isPending: filteredBody.isPending,
error: !!filteredBody.error,
}}
/>
);
}
@@ -1,10 +1,10 @@
import classNames from "classnames";
import { useEffect, useState } from "react";
import { platform } from "@yaakapp-internal/platform";
type Props = { className?: string; mimeType?: string } & (
| {
/** A URL the host resolved, for a body it already stored. */
url: string;
bodyPath: string;
}
| {
data: ArrayBuffer;
@@ -13,21 +13,21 @@ type Props = { className?: string; mimeType?: string } & (
export function ImageViewer({ className, mimeType, ...props }: Props) {
const [src, setSrc] = useState<string>();
const url = "url" in props ? props.url : null;
const bodyPath = "bodyPath" in props ? props.bodyPath : null;
const data = "data" in props ? props.data : null;
useEffect(() => {
if (url != null) {
setSrc(url);
if (bodyPath != null) {
setSrc(platform.files.url(bodyPath));
} else if (data != null) {
const blob = new Blob([data], { type: mimeType ?? "image/png" });
const objectUrl = URL.createObjectURL(blob);
setSrc(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
} else {
setSrc(undefined);
}
}, [url, data, mimeType]);
}, [bodyPath, data, mimeType]);
return (
<img
@@ -6,6 +6,7 @@ import { useMemo, useRef, useState } from "react";
import { Document, Page } from "react-pdf";
import { useContainerSize } from "@yaakapp-internal/ui";
import { fireAndForget } from "../../lib/fireAndForget";
import { platform } from "@yaakapp-internal/platform";
fireAndForget(
import("react-pdf").then(({ pdfjs }) => {
@@ -17,8 +18,7 @@ fireAndForget(
);
interface Props {
/** A URL the host resolved, for a body it already stored. */
url?: string;
bodyPath?: string;
data?: Uint8Array;
}
@@ -27,7 +27,7 @@ const options = {
standardFontDataUrl: "/standard_fonts/",
};
export function PdfViewer({ url, data }: Props) {
export function PdfViewer({ bodyPath, data }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [numPages, setNumPages] = useState<number>();
@@ -36,8 +36,8 @@ export function PdfViewer({ url, data }: Props) {
// During render, not in an effect: an effect leaves the first paint with no file, and
// `Document` renders its "Failed to load PDF file" state for that frame before recovering
const src = useMemo(() => {
if (url) {
return url;
if (bodyPath) {
return platform.files.url(bodyPath);
}
if (data) {
// Create a copy to avoid "Buffer is already detached" errors
@@ -45,7 +45,7 @@ export function PdfViewer({ url, data }: Props) {
return { data: new Uint8Array(data) };
}
return undefined;
}, [url, data]);
}, [bodyPath, data]);
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
setNumPages(nextNumPages);
@@ -0,0 +1,96 @@
import { Icon } from "@yaakapp-internal/ui";
import type { RecentFilter } from "../../hooks/useRecentFilters";
import { Dropdown, type DropdownItem } from "../core/Dropdown";
import { IconButton } from "../core/IconButton";
interface Props {
recentFilters: RecentFilter[];
activeFilter: string | null;
onSelect: (value: string) => void;
onRemove: (value: string) => void;
onTogglePin: (value: string) => void;
onClear: () => void;
}
export function RecentFiltersDropdown({
recentFilters,
activeFilter,
onSelect,
onRemove,
onTogglePin,
onClear,
}: Props) {
const pinned = recentFilters.filter((f) => f.pinned);
const unpinned = recentFilters.filter((f) => !f.pinned);
const toItem = (filter: RecentFilter): DropdownItem => ({
label: (
<div className="font-mono text-sm truncate max-w-sm" title={filter.value}>
{filter.value}
</div>
),
leftSlot: <Icon icon={filter.value === activeFilter ? "check" : "empty"} />,
onSelect: () => onSelect(filter.value),
submenuTrigger: "button",
submenu: [
{
label: filter.pinned ? "Unpin" : "Pin",
icon: filter.pinned ? "unpin" : "pin",
keepOpenOnSelect: true,
onSelect: () => onTogglePin(filter.value),
},
{
label: "Remove",
icon: "trash",
color: "danger",
keepOpenOnSelect: true,
onSelect: () => onRemove(filter.value),
},
],
});
const items: DropdownItem[] = [];
if (recentFilters.length === 0) {
items.push({
type: "content",
label: (
<span className="block px-4 py-1 text-sm text-text-subtle">
Filters you use are remembered here
</span>
),
});
}
if (pinned.length > 0) {
items.push({ type: "separator", label: "Pinned" }, ...pinned.map(toItem));
}
if (unpinned.length > 0) {
items.push({ type: "separator", label: "Recent" }, ...unpinned.map(toItem));
}
if (recentFilters.length > 0) {
items.push(
{ type: "separator" },
{
label: "Clear All",
leftSlot: <Icon icon="trash" />,
color: "danger",
onSelect: onClear,
},
);
}
return (
<Dropdown items={items}>
<IconButton
size="xs"
icon="filter"
title="Recent filters"
iconColor="secondary"
className="w-8 ml-0.5 mr-1 h-auto!"
/>
</Dropdown>
);
}
@@ -1,14 +1,15 @@
import classNames from "classnames";
import type { ReactNode } from "react";
import { Children, useCallback, useMemo } from "react";
import { createGlobalState } from "react-use";
import { useDebouncedValue } from "@yaakapp-internal/ui";
import { Banner, HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
import { useFormatText } from "../../hooks/useFormatText";
import type { ResponseFilterApi } from "../../hooks/useResponseFilter";
import { Button } from "../core/Button";
import type { EditorProps } from "../core/Editor/Editor";
import { hyperlink } from "../core/Editor/hyperlink/extension";
import { Editor } from "../core/Editor/LazyEditor";
import { IconButton } from "../core/IconButton";
import { Input } from "../core/Input";
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
const extraExtensions = [hyperlink];
@@ -16,57 +17,45 @@ interface Props {
text: string;
language: EditorProps["language"];
stateKey: string | null;
filterStateKey?: string | null;
pretty?: boolean;
className?: string;
footerActions?: ReactNode;
onFilter?: (filter: string) => {
filter?: ResponseFilterApi;
filterResult?: {
data: string | null | undefined;
isPending: boolean;
error: boolean;
};
}
const useFilterText = createGlobalState<Record<string, string | null>>({});
export function TextViewer({
language,
text,
stateKey,
filterStateKey,
pretty,
className,
footerActions,
onFilter,
filter,
filterResult,
}: Props) {
const filterKey = filterStateKey ?? stateKey;
const [filterTextMap, setFilterTextMap] = useFilterText();
const filterText = filterKey ? (filterTextMap[filterKey] ?? null) : null;
const debouncedFilterText = useDebouncedValue(filterText);
const setFilterText = useCallback(
(v: string | null) => {
if (!filterKey) return;
setFilterTextMap((m) => ({ ...m, [filterKey]: v }));
const canFilter =
filter != null && (language === "json" || language === "xml" || language === "html");
const isSearching = filter?.isSearching ?? false;
const appliedFilter = filter?.appliedFilter ?? null;
const resultError = filterResult?.error ?? false;
const handleFilterKeyDown = useCallback(
(e: KeyboardEvent) => {
if (filter == null) return;
if (e.key === "Escape") {
filter.toggleSearch();
} else if (e.key === "Enter" && filter.filterText != null) {
filter.applyFilter(filter.filterText);
}
},
[filterKey, setFilterTextMap],
[filter],
);
const isSearching = filterText != null;
const filteredResponse =
onFilter && debouncedFilterText
? onFilter(debouncedFilterText)
: { data: null, isPending: false, error: false };
const toggleSearch = useCallback(() => {
if (isSearching) {
setFilterText(null);
} else {
setFilterText("");
}
}, [isSearching, setFilterText]);
const canFilter = onFilter && (language === "json" || language === "xml" || language === "html");
const actions = useMemo<ReactNode[]>(() => {
const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
@@ -76,8 +65,8 @@ export function TextViewer({
nodes.push(
<div key="input" className="w-full opacity-100!">
<Input
key={filterKey ?? "filter"}
validate={!filteredResponse.error}
key={filter.stateKey ?? "filter"}
validate={!resultError}
hideLabel
autoFocus
containerClassName="bg-surface"
@@ -85,39 +74,62 @@ export function TextViewer({
placeholder={language === "json" ? "JSONPath expression" : "XPath expression"}
label="Filter expression"
name="filter"
defaultValue={filterText}
onKeyDown={(e) => e.key === "Escape" && toggleSearch()}
onChange={setFilterText}
stateKey={filterKey ? `filter.${filterKey}` : null}
defaultValue={filter.filterText}
forceUpdateKey={filter.filterUpdateKey}
onKeyDown={handleFilterKeyDown}
onChange={filter.setFilterText}
stateKey={filter.stateKey ? `filter.${filter.stateKey}` : null}
leftSlot={
<div className="py-0.5 flex">
<RecentFiltersDropdown
recentFilters={filter.recentFilters}
activeFilter={filter.appliedFilter}
onSelect={filter.replaceFilter}
onRemove={filter.removeRecentFilter}
onTogglePin={filter.togglePinRecentFilter}
onClear={filter.clearRecentFilters}
/>
</div>
}
rightSlot={
<div className="py-0.5 flex">
<IconButton
size="xs"
icon="x"
title="Close filter"
iconColor="secondary"
onClick={filter.toggleSearch}
className="w-8 mr-0.5 h-auto!"
/>
</div>
}
/>
</div>,
);
} else {
nodes.push(
<IconButton
key="icon"
size="sm"
isLoading={filterResult?.isPending ?? false}
icon="filter"
title="Filter response"
onClick={filter.toggleSearch}
className="border border-border-subtle!"
/>,
);
}
nodes.push(
<IconButton
key="icon"
size="sm"
isLoading={filteredResponse.isPending}
icon={isSearching ? "x" : "filter"}
title={isSearching ? "Close filter" : "Filter response"}
onClick={toggleSearch}
className={classNames("border border-border-subtle!", isSearching && "opacity-100!")}
/>,
);
return nodes;
}, [
canFilter,
footerActions,
filterKey,
filterText,
filteredResponse.error,
filteredResponse.isPending,
filter,
filterResult?.isPending,
resultError,
isSearching,
language,
setFilterText,
toggleSearch,
handleFilterKeyDown,
]);
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
@@ -126,11 +138,11 @@ export function TextViewer({
}
let body: string;
if (isSearching && filterText?.length > 0) {
if (filteredResponse.error) {
if (appliedFilter) {
if (resultError) {
body = "";
} else {
body = filteredResponse.data != null ? filteredResponse.data : "";
body = filterResult?.data != null ? filterResult.data : "";
}
} else {
body = formattedBody;
@@ -143,15 +155,61 @@ export function TextViewer({
}
return (
<Editor
readOnly
className={className}
defaultValue={body}
language={language}
actions={actions}
extraExtensions={extraExtensions}
stateKey={stateKey}
/>
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
{appliedFilter && filter != null ? (
<AppliedFilterBar
filter={appliedFilter}
error={resultError}
onClear={() => filter.replaceFilter("")}
/>
) : (
<span />
)}
<Editor
readOnly
className={className}
defaultValue={body}
language={language}
actions={actions}
extraExtensions={extraExtensions}
stateKey={stateKey}
/>
</div>
);
}
/**
* Shows what's actually filtering the body, which the filter box below can't convey
* once it holds an edited expression that hasn't been applied yet.
*/
function AppliedFilterBar({
filter,
error,
onClear,
}: {
filter: string;
error: boolean;
onClear: () => void;
}) {
return (
<Banner color={error ? "danger" : "info"} className="py-1! mb-2! text-sm">
<HStack space={2} className="min-w-0">
<Icon icon="filter" size="xs" className="shrink-0 opacity-70" />
<span className="truncate min-w-0" title={filter}>
Response filtered by <InlineCode>{filter}</InlineCode>
{error && " (invalid expression)"}
</span>
<Button
size="2xs"
variant="border"
color={error ? "danger" : "info"}
className="ml-auto shrink-0"
onClick={onClear}
>
Clear
</Button>
</HStack>
</Banner>
);
}
@@ -1,28 +1,28 @@
import { useEffect, useState } from "react";
import { platform } from "@yaakapp-internal/platform";
interface Props {
/** A URL the host resolved, for a body it already stored. */
url?: string;
bodyPath?: string;
data?: Uint8Array;
mimeType?: string;
}
export function VideoViewer({ url, data, mimeType }: Props) {
export function VideoViewer({ bodyPath, data, mimeType }: Props) {
const [src, setSrc] = useState<string>();
useEffect(() => {
if (url) {
setSrc(url);
if (bodyPath) {
setSrc(platform.files.url(bodyPath));
} else if (data) {
// As in AudioViewer: a media element trusts the declared type instead of sniffing
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" });
const objectUrl = URL.createObjectURL(blob);
setSrc(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
} else {
setSrc(undefined);
}
}, [url, data, mimeType]);
}, [bodyPath, data, mimeType]);
// oxlint-disable-next-line jsx-a11y/media-has-caption
return <video className="w-full" controls src={src} />;
@@ -0,0 +1,67 @@
import { useCallback } from "react";
import { useKeyValue } from "./useKeyValue";
export interface RecentFilter {
value: string;
pinned?: boolean;
}
const MAX_RECENT_FILTERS = 20;
const kvKey = (filterStateKey: string) => `recent_filters::${filterStateKey}`;
const namespace = "global";
const fallback: RecentFilter[] = [];
export function useRecentFilters(filterStateKey: string | null) {
const { value, set } = useKeyValue<RecentFilter[]>({
key: kvKey(filterStateKey ?? "n/a"),
namespace,
fallback,
});
const addFilter = useCallback(
async (rawValue: string) => {
const value = rawValue.trim();
if (filterStateKey == null || value === "") return;
await set((prev) => {
// Returning the same reference skips the write, so re-committing the
// expression already at the top (on every blur) costs nothing
if (prev[0]?.value === value) return prev;
const existing = prev.find((f) => f.value === value);
const rest = prev.filter((f) => f.value !== value);
return trim([{ value, pinned: existing?.pinned }, ...rest]);
});
},
[filterStateKey, set],
);
const removeFilter = useCallback(
async (value: string) => set((prev) => prev.filter((f) => f.value !== value)),
[set],
);
const togglePin = useCallback(
async (value: string) =>
set((prev) => prev.map((f) => (f.value === value ? { ...f, pinned: !f.pinned } : f))),
[set],
);
const clearFilters = useCallback(async () => set([]), [set]);
return { recentFilters: value ?? fallback, addFilter, removeFilter, togglePin, clearFilters };
}
/** Bound the list, evicting the oldest unpinned entries before any pinned ones */
function trim(filters: RecentFilter[]): RecentFilter[] {
const excess = filters.length - MAX_RECENT_FILTERS;
if (excess <= 0) return filters;
const evicted = new Set<number>();
for (let i = filters.length - 1; i >= 0 && evicted.size < excess; i--) {
if (!filters[i]?.pinned) evicted.add(i);
}
for (let i = filters.length - 1; i >= 0 && evicted.size < excess; i--) {
evicted.add(i);
}
return filters.filter((_, i) => !evicted.has(i));
}
+20 -8
View File
@@ -2,6 +2,25 @@ import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import { getResponseBodyBytes, getResponseBodyText } from "../lib/responseBody";
export function responseBodyTextQuery({
response,
filter,
}: {
response: HttpResponse;
filter: string | null;
}) {
return {
queryKey: [
"response_body_text",
response.id,
response.updatedAt,
response.contentLength,
filter ?? "",
],
queryFn: () => getResponseBodyText({ response, filter }),
};
}
export function useResponseBodyText({
response,
filter,
@@ -11,14 +30,7 @@ export function useResponseBodyText({
}) {
return useQuery({
placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: [
"response_body_text",
response.id,
response.updatedAt,
response.contentLength,
filter ?? "",
],
queryFn: () => getResponseBodyText({ response, filter }),
...responseBodyTextQuery({ response, filter }),
});
}
@@ -1,23 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
/**
* A URL for a stored response body, for the viewers that hand one to an element
* instead of reading the bytes themselves.
*
* Resolved once here rather than inside each viewer: the host may have to ask
* the backend where the body is, and a viewer that computes its source during
* render (the PDF one, deliberately) needs it settled before it mounts.
*
* Null data means the response has no stored body.
*/
export function useResponseBodyUrl(response: HttpResponse | null) {
const responseId = response?.id ?? null;
return useQuery({
queryKey: ["response_body_url", responseId, response?.updatedAt ?? ""],
enabled: responseId != null,
queryFn: () => (responseId == null ? null : platform.files.responseBodyUrl(responseId)),
});
}
+129
View File
@@ -0,0 +1,129 @@
import { useCallback, useState } from "react";
import { createGlobalState } from "react-use";
import type { RecentFilter } from "./useRecentFilters";
import { useRecentFilters } from "./useRecentFilters";
/** What's typed in the filter box. `null` means the filter box is closed */
const useFilterTextMap = createGlobalState<Record<string, string | null>>({});
/** What's actually applied to the response. Only changes on an explicit apply */
const useAppliedFilterMap = createGlobalState<Record<string, string | null>>({});
export interface ResponseFilterApi {
stateKey: string | null;
/** Draft text in the filter box, or `null` when the box is closed */
filterText: string | null;
/** The expression currently filtering the response */
appliedFilter: string | null;
isSearching: boolean;
/** The box holds an expression that isn't the one currently applied */
isDirty: boolean;
/** Bumped when the (uncontrolled) filter input must re-read its defaultValue */
filterUpdateKey: number;
setFilterText: (value: string | null) => void;
/** Apply the expression to the response, recording it if the filter accepts it */
applyFilter: (value: string) => void;
/** Like applyFilter, but also replaces what's shown in the filter box */
replaceFilter: (value: string) => void;
toggleSearch: () => void;
recentFilters: RecentFilter[];
removeRecentFilter: (value: string) => void;
togglePinRecentFilter: (value: string) => void;
clearRecentFilters: () => void;
}
/**
* Draft/applied state and history for a response filter (JSONPath/XPath).
*
* History records at most one entry per apply gesture, and only after `runFilter`
* confirms the plugin accepts the expression — the plugin is the sole judge of
* validity. Because nothing but the gesture ever writes, refetches can't resurrect
* deleted entries and a gesture can't record into another request's history.
*/
export function useResponseFilter({
stateKey,
runFilter,
}: {
stateKey: string | null;
/** Evaluate an expression, rejecting if the filter plugin reports an error */
runFilter: (filter: string) => Promise<unknown>;
}): ResponseFilterApi {
const [filterTextMap, setFilterTextMap] = useFilterTextMap();
const [appliedFilterMap, setAppliedFilterMap] = useAppliedFilterMap();
const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null;
const appliedFilter = stateKey ? (appliedFilterMap[stateKey] ?? null) : null;
const setFilterText = useCallback(
(v: string | null) => {
if (!stateKey) return;
setFilterTextMap((m) => ({ ...m, [stateKey]: v }));
},
[stateKey, setFilterTextMap],
);
const setAppliedFilter = useCallback(
(v: string | null) => {
if (!stateKey) return;
setAppliedFilterMap((m) => ({ ...m, [stateKey]: v }));
},
[stateKey, setAppliedFilterMap],
);
const {
recentFilters,
addFilter,
removeFilter: removeRecentFilter,
togglePin: togglePinRecentFilter,
clearFilters: clearRecentFilters,
} = useRecentFilters(stateKey);
const applyFilter = useCallback(
(value: string) => {
setFilterText(value);
const applied = value.trim() === "" ? null : value.trim();
setAppliedFilter(applied);
if (applied == null) return;
runFilter(applied).then(
() => addFilter(applied),
() => {}, // Rejected by the filter plugin — don't record
);
},
[setFilterText, setAppliedFilter, runFilter, addFilter],
);
const [filterUpdateKey, setFilterUpdateKey] = useState(0);
const replaceFilter = useCallback(
(value: string) => {
applyFilter(value);
setFilterUpdateKey((k) => k + 1);
},
[applyFilter],
);
const isSearching = filterText != null;
const toggleSearch = useCallback(() => {
if (isSearching) {
setFilterText(null);
setAppliedFilter(null);
} else {
setFilterText("");
}
}, [isSearching, setFilterText, setAppliedFilter]);
return {
stateKey,
filterText,
appliedFilter,
isSearching,
isDirty: filterText != null && filterText.trim() !== (appliedFilter ?? ""),
filterUpdateKey,
setFilterText,
applyFilter,
replaceFilter,
toggleSearch,
recentFilters,
removeRecentFilter,
togglePinRecentFilter,
clearRecentFilters,
};
}
+10 -19
View File
@@ -5,12 +5,6 @@ import { candidateJsonPayloadsFromSseText, computeSseSummary } from "@yaakapp-in
import { rpc } from "./rpc";
import { platform } from "@yaakapp-internal/platform";
/**
* Reading a response body means naming the response, never the file it lives
* in: the backend resolves an id against its own records, so nothing the UI
* says can point a read somewhere else.
*/
export async function getResponseBodyText({
response,
filter,
@@ -19,7 +13,7 @@ export async function getResponseBodyText({
filter: string | null;
}): Promise<string | null> {
const result = await rpc<FilterResponse>("cmd_http_response_body", {
responseId: response.id,
response,
filter,
});
@@ -33,9 +27,10 @@ export async function getResponseBodyText({
export async function getResponseBodyEventSource(
response: HttpResponse,
): Promise<ServerSentEvent[]> {
if (!response.bodyPath) return [];
try {
const events = await rpc<ServerSentEvent[]>("cmd_get_sse_events", {
responseId: response.id,
filePath: response.bodyPath,
});
if (events.length > 0) {
return events;
@@ -44,9 +39,8 @@ export async function getResponseBodyEventSource(
// Fall back to raw JSON frame parsing for non-standard SSE-like responses.
}
const text = await getResponseBodyDecoded(response);
if (text == null) return [];
const bytes = await platform.files.readFile(response.bodyPath);
const text = new TextDecoder("utf-8").decode(bytes);
return candidateJsonPayloadsFromSseText(text).map((data, index) => ({
data,
eventType: "",
@@ -59,19 +53,16 @@ export async function getResponseBodySseSummary(
response: HttpResponse,
resultKeyPath: string,
): Promise<SseSummary> {
const text = await getResponseBodyDecoded(response);
if (text == null) return { fragmentCount: 0, summary: "" };
if (!response.bodyPath) return { fragmentCount: 0, summary: "" };
const bytes = await platform.files.readFile(response.bodyPath);
const text = new TextDecoder("utf-8").decode(bytes);
return computeSseSummary(text, resultKeyPath);
}
export async function getResponseBodyBytes(
response: HttpResponse,
): Promise<Uint8Array<ArrayBuffer> | null> {
return platform.files.readResponseBody(response.id);
}
async function getResponseBodyDecoded(response: HttpResponse): Promise<string | null> {
const bytes = await getResponseBodyBytes(response);
return bytes == null ? null : new TextDecoder("utf-8").decode(bytes);
if (!response.bodyPath) return null;
return platform.files.readFile(response.bodyPath);
}
-47
View File
@@ -1,47 +0,0 @@
[package]
name = "yaak-server"
version = "0.1.0"
edition = "2024"
publish = false
[[bin]]
name = "yaak-bridge"
path = "src/main.rs"
[dependencies]
axum = { version = "0.7", features = ["ws", "macros"] }
charset = "0.1"
chrono = { workspace = true }
clap = { version = "4", features = ["derive", "env"] }
dirs = "6"
env_logger = "0.11"
eventsource-client = { git = "https://github.com/yaakapp/rust-eventsource-client", version = "0.14.0" }
futures = "0.3"
include_dir = "0.7"
log = { workspace = true }
mime_guess = "2"
pretty_graphql = "0.2"
rand = "0.8"
serde = { workspace = true }
serde_json = { workspace = true }
serde_urlencoded = "0.7"
tokio = { workspace = true, features = [
"rt-multi-thread",
"macros",
"io-util",
"net",
"signal",
"time",
"sync",
] }
tower-http = { version = "0.6", features = ["cors", "fs", "trace"] }
yaak = { workspace = true }
yaak-common = { workspace = true }
yaak-core = { workspace = true }
yaak-crypto = { workspace = true }
yaak-http = { workspace = true }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-rpc = { workspace = true }
yaak-sse = { workspace = true }
yaak-templates = { workspace = true }
-94
View File
@@ -1,94 +0,0 @@
# Yaak Bridge
A headless binary that runs the real Yaak engine for a browser tab.
The tab is the unmodified Yaak UI. Everything a page cannot do — send an HTTP
request and see every response header, follow redirects, keep a cookie jar, run
the plugin runtime, read a response body off disk — happens in this process,
reached over local HTTP and a WebSocket.
This is the reason a browser Yaak can be credible at all. An in-page `fetch`
sender only ever sees the CORS-safelisted response headers: measured against
httpbin, a server that sent 8 headers yielded 2. Through the bridge the same
request yields all 8, plus the redirect chain, `Set-Cookie`, connection timings
and client certificates.
## Running it
Start the bridge:
```bash
cargo run -p yaak-server -- --port 9444
```
It binds `127.0.0.1` only and prints a bearer token that every route requires.
Then point a frontend at it. In dev, run Vite separately and tell it where the
bridge is:
```bash
YAAK_CLIENT_DEV_PORT=1472 VITE_YAAK_BRIDGE_URL=http://127.0.0.1:9444 npm run dev --workspace apps/yaak-client
```
Open `http://localhost:1472/?bridgeToken=<token>`. The token is consumed from
the query, kept for the session, and stripped from the address bar. Without one
you get a small connect form.
To serve the built frontend from the bridge itself instead, so there is only one
process:
```bash
npm run build --workspace apps/yaak-client
cargo run -p yaak-server -- --web-dir dist/apps/yaak-client
```
## Shape
| Route | What it carries |
| --- | --- |
| `POST /rpc` | The yaak-rpc envelope, the same one Tauri's `invoke` wraps on the desktop |
| `GET /events` | WebSocket. Server to client: `model_writes`, `stream_{id}`, toasts, plugin events. Client to server: the tab's location, and replies to prompts |
| `GET /responses/:id/body` | Response bodies, with Range support. Replaces reading `bodyPath` off disk |
| `GET /bridge/info` | Capabilities and the implemented command list |
Auth is a bearer token in the `Authorization` header, or a `token` query
parameter for the two requests the browser issues itself (the WebSocket, and
`<img src>`-style body loads). It is dev-grade and deliberately minimal: OTP
pairing and request encryption replace it, and `require_token` in `http.rs` is
where they go.
## Relationship to the other hosts
The engine crates under `crates/` are Tauri-free, and `crates-cli/yaak-cli`
already proved they run headless. This crate is structurally the CLI's
`CliContext` with an event hub attached — same `init_standalone` database, same
`PluginManager` over the same Node sidecar.
Two things are ported deliberately rather than invented:
- **Model writes** (`model_writes.rs`) keep the desktop's two paths: an
in-memory channel for writes this process made, and a poll of the
`model_changes` table so external writers — the CLI, the desktop app open on
the same database — show up live in the browser.
- **Plugin host requests** (`plugin_events.rs`) let `yaak::plugin_events`
answer everything that is only a database question, exactly as the CLI and the
desktop do. Only the host-specific arms differ, and where the CLI answers a
prompt from a TTY, the bridge round-trips it to the tab the way the desktop
round-trips it to a window.
## Known gaps
- **Settings is unreachable.** The desktop opens it via `cmd_new_child_window`.
A tab is one window, `multiWindow` is false, and this task did not add in-page
routing for it.
- **One tab at a time.** Model writes broadcast correctly to every connected
tab, so two tabs stay in sync for reads. What breaks is the session: the
tab's reported URL lives in a single slot, so with two tabs in different
workspaces a plugin's template render resolves against whichever attached
last. Prompts also broadcast, so a dialog raised by one tab appears in both.
- **No local files.** There is no file dialog, so request bodies from disk,
export, and save-response are unsupported. `cmd_import_data` is registered and
works, but only for a path typed by hand on the bridge's machine.
- **Command subset.** Roughly 40 of the desktop's 107 commands are implemented.
The rest return a structured "not supported on this host" error naming the
command; `UNSUPPORTED_COMMANDS` in `rpc/mod.rs` lists them.
-117
View File
@@ -1,117 +0,0 @@
//! The events channel: everything the browser tab would have received as a
//! Tauri window event.
//!
//! Two directions ride the same WebSocket. Server to client is a broadcast, so
//! `model_writes`, `stream_{id}` messages, toasts and plugin events all reach
//! the tab through one pipe. Client to server exists because some plugin host
//! requests are questions — a prompt round-trips through the UI and comes back
//! keyed by the originating event's id, exactly as the desktop app's
//! `call_frontend` does with window events.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio::sync::{broadcast, mpsc};
/// One frame in either direction: a name and a JSON payload.
///
/// Deliberately the same shape both ways, and the same shape as the desktop's
/// event payloads, so `platform.listen` on the browser side hands the payload
/// to callers unwrapped.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventFrame {
pub event: String,
#[serde(default)]
pub payload: serde_json::Value,
}
#[derive(Clone)]
pub struct EventHub {
outbound: broadcast::Sender<EventFrame>,
/// Listeners waiting on a named event from the client, keyed by event name.
inbound: Arc<Mutex<HashMap<String, Vec<mpsc::UnboundedSender<serde_json::Value>>>>>,
}
/// A subscription to one named client-sent event. Deregisters on drop, so a
/// prompt that is never answered doesn't leak a listener for the process's life.
pub struct InboundSubscription {
event: String,
rx: mpsc::UnboundedReceiver<serde_json::Value>,
inbound: Arc<Mutex<HashMap<String, Vec<mpsc::UnboundedSender<serde_json::Value>>>>>,
}
impl InboundSubscription {
pub async fn recv(&mut self) -> Option<serde_json::Value> {
self.rx.recv().await
}
}
impl Drop for InboundSubscription {
fn drop(&mut self) {
let mut inbound = match self.inbound.lock() {
Ok(inbound) => inbound,
Err(poisoned) => poisoned.into_inner(),
};
if let Some(senders) = inbound.get_mut(&self.event) {
senders.retain(|tx| !tx.is_closed());
if senders.is_empty() {
inbound.remove(&self.event);
}
}
}
}
impl EventHub {
pub fn new() -> Self {
// Bounded: a tab that stops reading gets dropped frames rather than
// growing the server's memory without limit. Model writes are the
// high-volume case (imports, bulk deletes) and they arrive in batches.
let (outbound, _) = broadcast::channel(1024);
Self { outbound, inbound: Arc::new(Mutex::new(HashMap::new())) }
}
/// Send an event to every connected tab. Fails silently when none is
/// connected, which is the normal state before a browser attaches.
pub fn emit<T: Serialize>(&self, event: impl Into<String>, payload: &T) {
let payload = match serde_json::to_value(payload) {
Ok(payload) => payload,
Err(e) => {
log::warn!("Failed to serialize event payload: {e}");
return;
}
};
let _ = self.outbound.send(EventFrame { event: event.into(), payload });
}
pub fn subscribe(&self) -> broadcast::Receiver<EventFrame> {
self.outbound.subscribe()
}
/// Listen for a named event sent *by* the client.
pub fn subscribe_inbound(&self, event: impl Into<String>) -> InboundSubscription {
let event = event.into();
let (tx, rx) = mpsc::unbounded_channel();
let mut inbound = match self.inbound.lock() {
Ok(inbound) => inbound,
Err(poisoned) => poisoned.into_inner(),
};
inbound.entry(event.clone()).or_default().push(tx);
drop(inbound);
InboundSubscription { event, rx, inbound: Arc::clone(&self.inbound) }
}
/// Route a frame that arrived from a tab to whoever is waiting on it.
pub fn dispatch_inbound(&self, frame: EventFrame) {
let mut inbound = match self.inbound.lock() {
Ok(inbound) => inbound,
Err(poisoned) => poisoned.into_inner(),
};
let Some(senders) = inbound.get_mut(&frame.event) else {
return;
};
senders.retain(|tx| tx.send(frame.payload.clone()).is_ok());
if senders.is_empty() {
inbound.remove(&frame.event);
}
}
}
-355
View File
@@ -1,355 +0,0 @@
//! The front door: one HTTP surface for the browser tab.
//!
//! Three routes carry everything. `POST /rpc` is the yaak-rpc envelope, byte for
//! byte what the desktop puts inside Tauri's `invoke`. `GET /events` is the
//! WebSocket that replaces window events, in both directions. And
//! `GET /responses/:id/body` replaces reading `bodyPath` off disk, which a tab
//! cannot do.
use crate::events::EventFrame;
use crate::rpc::BridgeCtx;
use crate::session::SessionContext;
use crate::state::BridgeState;
use axum::body::Body;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, Request, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tower_http::cors::CorsLayer;
use yaak_rpc::{RpcRequest, RpcResponse, RpcRouter};
#[derive(Clone)]
pub struct AppState {
pub state: Arc<BridgeState>,
pub router: Arc<RpcRouter<BridgeCtx>>,
}
pub fn build_app(state: Arc<BridgeState>, router: Arc<RpcRouter<BridgeCtx>>) -> Router {
let app_state = AppState { state: state.clone(), router };
let api = Router::new()
.route("/bridge/info", get(bridge_info))
.route("/rpc", post(rpc_handler))
.route("/events", get(events_handler))
.route("/responses/:id/body", get(response_body))
.layer(axum::middleware::from_fn_with_state(state.clone(), require_token))
// The dev setup serves the frontend from Vite on another port, so the
// tab's origin is not the bridge's. Credentials never ride on cookies
// here — the token is explicit — so a permissive CORS layer is safe and
// is bounded by the token check that runs before it.
.layer(CorsLayer::permissive())
.with_state(app_state);
match std::env::var("YAAK_BRIDGE_WEB_DIR").ok() {
// Serving the built frontend makes the bridge a single process to run.
// `index.html` is the fallback because the router owns the paths.
Some(dir) => api.fallback_service(
tower_http::services::ServeDir::new(&dir)
.fallback(tower_http::services::ServeFile::new(format!("{dir}/index.html"))),
),
None => api,
}
}
// -- Auth --
#[derive(Debug, Deserialize)]
struct TokenQuery {
token: Option<String>,
}
/// Dev-grade bearer check on every route.
///
/// The header is the normal path. The query parameter exists because two of
/// these are opened by the browser itself — the WebSocket and the `<img src>`
/// pointing at a response body — and neither lets the page set headers.
///
/// This is the seam where OTP pairing and per-session keys go. It is not one
/// today: the token is a process-lifetime shared secret, and anything that can
/// read the tab's URL can read it.
async fn require_token(
State(state): State<Arc<BridgeState>>,
request: Request,
next: Next,
) -> Response {
let from_header = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|v| v.to_string());
let from_query = request
.uri()
.query()
.and_then(|q| serde_urlencoded::from_str::<TokenQuery>(q).ok())
.and_then(|q| q.token);
let presented = from_header.or(from_query);
match presented {
Some(token) if constant_time_eq(&token, &state.token) => next.run(request).await,
_ => (StatusCode::UNAUTHORIZED, "Invalid or missing bridge token").into_response(),
}
}
/// Compares without returning early on the first differing byte, so a caller
/// can't learn the token one character at a time.
fn constant_time_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
// -- Routes --
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct BridgeInfo {
name: String,
version: String,
capabilities: crate::state::BridgeCapabilities,
/// Commands this build implements. The browser host uses it to fail fast
/// with a clear message instead of waiting for a round trip.
commands: Vec<String>,
}
async fn bridge_info(State(app): State<AppState>) -> Json<BridgeInfo> {
Json(BridgeInfo {
name: "Yaak Bridge".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
capabilities: app.state.capabilities.clone(),
commands: crate::rpc::implemented_commands(&app.router),
})
}
/// One envelope in, one out. Errors are carried inside the envelope, not as an
/// HTTP status, so the browser host can reject the caller's promise with the
/// backend's own message.
async fn rpc_handler(
State(app): State<AppState>,
Json(req): Json<RpcRequest>,
) -> Json<RpcResponse> {
let ctx = BridgeCtx { state: app.state.clone(), session: app.state.session.get() };
log::debug!("RPC {}", req.cmd);
let response = app.router.handle(req, &ctx).await;
if let RpcResponse::Error { error, .. } = &response {
log::warn!("RPC failed: {error}");
}
Json(response)
}
async fn events_handler(State(app): State<AppState>, ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(move |socket| handle_events_socket(socket, app))
}
/// The tab's first frame reports who and where it is; everything after that is
/// a reply to something the server asked.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AttachPayload {
label: String,
url: String,
}
async fn handle_events_socket(socket: WebSocket, app: AppState) {
use futures::{SinkExt, StreamExt};
let (mut sink, mut stream) = socket.split();
let mut outbound = app.state.events.subscribe();
// Server to client.
let send_task = tokio::spawn(async move {
loop {
match outbound.recv().await {
Ok(frame) => {
let Ok(text) = serde_json::to_string(&frame) else {
continue;
};
if sink.send(Message::Text(text)).await.is_err() {
break;
}
}
// A tab that fell behind has missed writes, and the model store
// would be silently stale. Close instead, so a reconnect
// re-reads the workspace from scratch.
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
log::warn!("Events client lagged by {n} frames; closing so it resyncs");
break;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
// Client to server.
let state = app.state.clone();
let recv_task = tokio::spawn(async move {
while let Some(Ok(message)) = stream.next().await {
let Message::Text(text) = message else {
continue;
};
let Ok(frame) = serde_json::from_str::<EventFrame>(&text) else {
log::warn!("Ignoring malformed event frame from browser");
continue;
};
// `bridge_attach` is the browser telling us what the desktop would
// have read off the window: its label and its current URL.
if frame.event == "bridge_attach" {
match serde_json::from_value::<AttachPayload>(frame.payload.clone()) {
Ok(attach) => {
log::info!("Browser attached: {} at {}", attach.label, attach.url);
state.session.set(SessionContext {
label: attach.label,
url: attach.url,
});
}
Err(e) => log::warn!("Bad bridge_attach payload: {e}"),
}
continue;
}
state.events.dispatch_inbound(frame);
}
});
tokio::select! {
_ = send_task => {},
_ = recv_task => {},
}
}
#[derive(Debug, Deserialize)]
struct BodyQuery {
/// Present so the shared token extractor doesn't reject the request; the
/// value itself is checked in the middleware.
#[allow(dead_code)]
token: Option<String>,
}
/// Stream a response body, with Range support.
///
/// Keyed by response id rather than by path: the tab hands back a `bodyPath`
/// the backend gave it, and resolving that through the database means this
/// route can only ever serve a file the engine wrote, not an arbitrary path a
/// page asked for. Range matters because the video and audio viewers seek.
async fn response_body(
State(app): State<AppState>,
Path(id): Path<String>,
Query(_q): Query<BodyQuery>,
headers: HeaderMap,
) -> Response {
let response = match app.state.db().get_http_response(&id) {
Ok(response) => response,
Err(_) => return (StatusCode::NOT_FOUND, "No such response").into_response(),
};
let Some(body_path) = response.body_path else {
return (StatusCode::NOT_FOUND, "Response has no body").into_response();
};
let mut file = match tokio::fs::File::open(&body_path).await {
Ok(file) => file,
Err(e) => return (StatusCode::NOT_FOUND, format!("Body unavailable: {e}")).into_response(),
};
let total = match file.metadata().await {
Ok(meta) => meta.len(),
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Body unreadable: {e}"))
.into_response();
}
};
let content_type = response
.headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
.map(|h| h.value.clone())
.unwrap_or_else(|| "application/octet-stream".to_string());
let range = headers.get(header::RANGE).and_then(|v| v.to_str().ok()).and_then(parse_range);
let (start, end, status) = match range {
Some((start, end)) => {
let end = end.unwrap_or(total.saturating_sub(1)).min(total.saturating_sub(1));
if total == 0 || start > end {
return Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
.body(Body::empty())
.unwrap();
}
(start, end, StatusCode::PARTIAL_CONTENT)
}
None => (0, total.saturating_sub(1), StatusCode::OK),
};
let length = if total == 0 { 0 } else { end - start + 1 };
if file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to seek body").into_response();
}
let mut buf = vec![0u8; length as usize];
if let Err(e) = file.read_exact(&mut buf).await {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read body: {e}"))
.into_response();
}
let mut builder = Response::builder()
.status(status)
.header(header::CONTENT_TYPE, content_type)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_LENGTH, length);
if status == StatusCode::PARTIAL_CONTENT {
builder = builder.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}"));
}
builder.body(Body::from(buf)).unwrap()
}
/// Parses a single `bytes=start-end` range. Multi-range requests are not
/// answered as multipart; the first range is used, which browsers accept.
fn parse_range(value: &str) -> Option<(u64, Option<u64>)> {
let spec = value.strip_prefix("bytes=")?.split(',').next()?.trim();
let (start, end) = spec.split_once('-')?;
if start.is_empty() {
return None;
}
let start: u64 = start.parse().ok()?;
let end = if end.is_empty() { None } else { Some(end.parse().ok()?) };
Some((start, end))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_ranges() {
assert_eq!(parse_range("bytes=0-499"), Some((0, Some(499))));
assert_eq!(parse_range("bytes=500-"), Some((500, None)));
assert_eq!(parse_range("bytes=0-99,200-299"), Some((0, Some(99))));
// Suffix ranges ("last 500 bytes") aren't supported; callers get the
// whole body, which is correct if wasteful.
assert_eq!(parse_range("bytes=-500"), None);
assert_eq!(parse_range("nonsense"), None);
}
#[test]
fn token_comparison_requires_exact_match() {
assert!(constant_time_eq("abc", "abc"));
assert!(!constant_time_eq("abc", "abd"));
assert!(!constant_time_eq("abc", "abcd"));
}
}
-121
View File
@@ -1,121 +0,0 @@
//! Yaak Bridge — the local companion that runs the real Yaak engine for a
//! browser tab.
//!
//! The tab is the Yaak UI, unchanged. Everything it cannot do in a page —
//! sending an HTTP request and seeing every response header, following
//! redirects, keeping a cookie jar, running plugins, reading a response body
//! off disk — happens in this process, over a local HTTP and WebSocket
//! connection.
//!
//! Loopback only, and every route needs the token printed at startup.
mod events;
mod http;
mod model_writes;
mod plugin_events;
mod rpc;
mod session;
mod state;
use clap::Parser;
use rand::Rng;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
const APP_ID: &str = "app.yaak.bridge";
#[derive(Parser, Debug)]
#[command(name = "yaak-bridge", about = "Run the Yaak engine for a browser tab")]
struct Args {
/// Port to listen on. Loopback only, always.
#[arg(long, default_value_t = 9444, env = "YAAK_BRIDGE_PORT")]
port: u16,
/// Where the database, plugins and response bodies live.
#[arg(long, env = "YAAK_BRIDGE_DATA_DIR")]
data_dir: Option<PathBuf>,
/// Use a fixed token instead of generating one. For scripted dev loops.
#[arg(long, env = "YAAK_BRIDGE_TOKEN")]
token: Option<String>,
/// Where the frontend was built to. Serving it makes this the only process
/// to run; without it, point a Vite dev server at this bridge instead.
#[arg(long, env = "YAAK_BRIDGE_WEB_DIR")]
web_dir: Option<PathBuf>,
}
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let args = Args::parse();
let data_dir = args.data_dir.unwrap_or_else(default_data_dir);
if let Err(e) = std::fs::create_dir_all(&data_dir) {
eprintln!("Error: failed to create data dir {}: {e}", data_dir.display());
std::process::exit(1);
}
if let Some(web_dir) = &args.web_dir {
// Read back by the router; keeping it in the environment avoids
// threading an option through every layer for a dev-mode convenience.
unsafe { std::env::set_var("YAAK_BRIDGE_WEB_DIR", web_dir) };
}
let token = args.token.unwrap_or_else(generate_token);
let is_dev = cfg!(debug_assertions);
let mut state = state::BridgeState::new(data_dir.clone(), APP_ID, token.clone(), is_dev);
state.init_plugins().await;
let state = Arc::new(state);
let router = Arc::new(rpc::build_router());
let app = http::build_app(state.clone(), router);
let addr = SocketAddr::from(([127, 0, 0, 1], args.port));
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(listener) => listener,
Err(e) => {
eprintln!("Error: failed to bind {addr}: {e}");
std::process::exit(1);
}
};
let base = format!("http://127.0.0.1:{}", args.port);
println!();
println!(" Yaak Bridge listening on {base}");
println!(" Data dir: {}", data_dir.display());
println!(" Plugins: {}", if state.capabilities.plugins { "running" } else { "unavailable" });
println!();
if std::env::var("YAAK_BRIDGE_WEB_DIR").is_ok() {
println!(" Open: {base}/?bridgeToken={token}");
} else {
println!(" Token: {token}");
println!(" Open your dev server with ?bridgeToken={token}");
}
println!();
let shutdown_state = state.clone();
let server = axum::serve(listener, app).with_graceful_shutdown(async move {
let _ = tokio::signal::ctrl_c().await;
log::info!("Shutting down");
shutdown_state.shutdown().await;
});
if let Err(e) = server.await {
eprintln!("Error: server failed: {e}");
std::process::exit(1);
}
}
fn default_data_dir() -> PathBuf {
dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")).join("yaak-bridge")
}
/// A 256-bit random token, hex encoded. Per process, never written to disk.
fn generate_token() -> String {
let bytes: [u8; 32] = rand::thread_rng().r#gen();
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
@@ -1,125 +0,0 @@
//! Pushing model writes to the connected tab.
//!
//! A direct port of the desktop's two paths (see
//! crates-tauri/yaak-app-client/src/models_ext.rs), and for the same reason:
//! the in-memory channel is the fast path for writes this process made on a
//! client's behalf, while polling the `model_changes` table is what makes an
//! external writer — the CLI, a second bridge, the desktop app open on the same
//! database — show up live in the browser. Keeping both means the browser
//! behaves like the desktop rather than like a cache.
use crate::events::EventHub;
use chrono::Utc;
use log::error;
use std::sync::mpsc::Receiver;
use std::time::Duration;
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{ModelPayload, UpdateSource};
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;
const MODEL_CHANGES_POLL_BATCH_SIZE: usize = 200;
struct ModelChangeCursor {
created_at: String,
id: i64,
}
impl ModelChangeCursor {
fn from_launch_time() -> Self {
Self {
created_at: Utc::now().naive_utc().format("%Y-%m-%d %H:%M:%S%.3f").to_string(),
id: 0,
}
}
}
pub fn start(query_manager: &QueryManager, rx: Receiver<ModelPayload>, events: EventHub) {
if let Err(err) =
query_manager.connect().prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)
{
error!("Failed to prune model_changes rows on startup: {err:?}");
}
// Only stream writes that happen after this process started.
let cursor = ModelChangeCursor::from_launch_time();
let poll_query_manager = query_manager.clone();
let poll_events = events.clone();
tokio::spawn(async move {
run_model_change_poller(poll_query_manager, poll_events, cursor).await;
});
// `init_standalone` hands back a std (blocking) receiver, so it gets a
// thread rather than a task.
std::thread::spawn(move || {
while let Ok(payload) = rx.recv() {
let mut batch: Vec<ModelPayload> = Vec::new();
if matches!(payload.update_source, UpdateSource::Window { .. }) {
batch.push(payload);
}
// Coalesce anything already queued into the same frame.
while let Ok(next) = rx.try_recv() {
if matches!(next.update_source, UpdateSource::Window { .. }) {
batch.push(next);
}
}
if batch.is_empty() {
continue;
}
events.emit("model_writes", &batch);
}
});
}
async fn run_model_change_poller(
query_manager: QueryManager,
events: EventHub,
mut cursor: ModelChangeCursor,
) {
loop {
while drain_model_changes_batch(&query_manager, &events, &mut cursor) {}
tokio::time::sleep(Duration::from_millis(MODEL_CHANGES_POLL_INTERVAL_MS)).await;
}
}
fn drain_model_changes_batch(
query_manager: &QueryManager,
events: &EventHub,
cursor: &mut ModelChangeCursor,
) -> bool {
let changes = match query_manager.connect().list_model_changes_since(
&cursor.created_at,
cursor.id,
MODEL_CHANGES_POLL_BATCH_SIZE,
) {
Ok(changes) => changes,
Err(err) => {
error!("Failed to poll model_changes rows: {err:?}");
return false;
}
};
if changes.is_empty() {
return false;
}
let fetched_count = changes.len();
let mut batch: Vec<ModelPayload> = Vec::with_capacity(fetched_count);
for change in changes {
cursor.created_at = change.created_at;
cursor.id = change.id;
// Window-sourced writes already went out on the in-memory fast path.
if matches!(change.payload.update_source, UpdateSource::Window { .. }) {
continue;
}
batch.push(change.payload);
}
// One batch per drain so bulk writes don't flood the tab.
if !batch.is_empty() {
events.emit("model_writes", &batch);
}
fetched_count == MODEL_CHANGES_POLL_BATCH_SIZE
}
@@ -1,582 +0,0 @@
//! The bridge's plugin host.
//!
//! Same shape as the CLI's bridge (crates-cli/yaak-cli/src/plugin_events.rs):
//! subscribe to the plugin manager, let `handle_shared_plugin_event` answer
//! everything that is only a database question, and implement the rest here.
//!
//! Where it differs is that a UI is attached. The CLI answers a prompt from a
//! TTY and refuses when there isn't one; the bridge does what the desktop does
//! instead — pushes the event to the tab and waits for the reply keyed by the
//! event's id. Toasts, clipboard writes and external URLs go the same way,
//! because the browser is the only thing here that can show or do them.
use crate::events::EventHub;
use crate::session::SessionStore;
use serde_json::Value;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::task::JoinHandle;
use yaak::plugin_events::{
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
};
use yaak::render::{render_grpc_request, render_http_request};
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_crypto::manager::EncryptionManager;
use yaak_http::cookies::get_cookie_value_from_jar;
use yaak_http::manager::HttpConnectionManager;
use yaak_models::blob_manager::BlobManager;
use yaak_models::models::Environment;
use yaak_models::queries::any_request::AnyRequest;
use yaak_models::query_manager::QueryManager;
use yaak_models::render::make_vars_hashmap;
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{
EmptyPayload, ErrorResponse, GetCookieValueResponse, InternalEvent, InternalEventPayload,
ListCookieNamesResponse, ListOpenWorkspacesResponse, PluginContext, PromptTextResponse,
RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse,
TemplateRenderResponse, WindowInfoResponse, WorkspaceInfo,
};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_handle::PluginHandle;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::{RenderOptions, TemplateCallback, render_json_value_raw};
pub struct BridgePluginEventBridge {
rx_id: String,
task: JoinHandle<()>,
}
struct BridgeHostContext {
query_manager: QueryManager,
blob_manager: BlobManager,
plugin_manager: Arc<PluginManager>,
encryption_manager: Arc<EncryptionManager>,
connection_manager: Arc<HttpConnectionManager>,
response_dir: PathBuf,
events: EventHub,
session: SessionStore,
}
impl BridgePluginEventBridge {
#[allow(clippy::too_many_arguments)]
pub async fn start(
plugin_manager: Arc<PluginManager>,
query_manager: QueryManager,
blob_manager: BlobManager,
encryption_manager: Arc<EncryptionManager>,
connection_manager: Arc<HttpConnectionManager>,
data_dir: PathBuf,
events: EventHub,
session: SessionStore,
) -> Self {
let (rx_id, mut rx) = plugin_manager.subscribe("bridge").await;
let rx_id_for_task = rx_id.clone();
let pm = plugin_manager.clone();
let host_context = Arc::new(BridgeHostContext {
query_manager,
blob_manager,
plugin_manager,
encryption_manager,
connection_manager,
response_dir: data_dir.join("responses"),
events,
session,
});
let task = tokio::spawn(async move {
while let Some(event) = rx.recv().await {
// Events with reply IDs are replies to app-originated requests.
if event.reply_id.is_some() {
continue;
}
let Some(plugin_handle) = pm.get_plugin_by_ref_id(&event.plugin_ref_id).await
else {
log::warn!(
"Ignoring plugin event with unknown plugin ref '{}'",
event.plugin_ref_id
);
continue;
};
let pm = pm.clone();
let host_context = host_context.clone();
// Avoid deadlocks for nested plugin-host requests (for example, template functions
// that trigger additional host requests during render) by handling each event in
// its own task.
tokio::spawn(async move {
let plugin_name = plugin_handle.info().name;
let Some(reply_payload) = build_plugin_reply(
host_context.as_ref(),
&event,
&plugin_name,
&plugin_handle,
)
.await
else {
return;
};
if let Err(err) = pm.reply(&event, &reply_payload).await {
log::warn!("Failed replying to plugin event: {err}");
}
});
}
pm.unsubscribe(&rx_id_for_task).await;
});
Self { rx_id, task }
}
pub async fn shutdown(self, plugin_manager: &PluginManager) {
plugin_manager.unsubscribe(&self.rx_id).await;
self.task.abort();
let _ = self.task.await;
}
}
async fn build_plugin_reply(
host_context: &BridgeHostContext,
event: &InternalEvent,
plugin_name: &str,
plugin_handle: &PluginHandle,
) -> Option<InternalEventPayload> {
let session = host_context.session.get();
let shared_workspace_id =
event.context.workspace_id.clone().or_else(|| session.workspace_id());
match handle_shared_plugin_event(
&host_context.query_manager,
&event.payload,
SharedPluginEventContext {
plugin_name,
workspace_id: shared_workspace_id.as_deref(),
},
) {
GroupedPluginEvent::Handled(payload) => payload,
GroupedPluginEvent::ToHandle(host_request) => match host_request {
HostRequest::ErrorResponse(resp) => {
log::warn!("[plugin:{plugin_name}] error: {}", resp.error);
None
}
HostRequest::ReloadResponse(_) => None,
// The tab owns everything the user can see or the OS can do. These
// are fire-and-forget: the plugin gets its acknowledgement as soon
// as the frame is queued, matching the desktop, which also does not
// wait for the webview to paint.
HostRequest::ShowToast(req) => {
host_context.events.emit("show_toast", &req);
Some(InternalEventPayload::ShowToastResponse(EmptyPayload {}))
}
HostRequest::CopyText(req) => {
host_context.events.emit("bridge_copy_text", &req);
Some(InternalEventPayload::CopyTextResponse(EmptyPayload {}))
}
HostRequest::OpenExternalUrl(req) => {
host_context.events.emit("bridge_open_url", &req);
Some(InternalEventPayload::OpenExternalUrlResponse(EmptyPayload {}))
}
// Prompts are questions, so they round-trip: the tab renders the
// dialog and emits the answer back under the event's own id.
HostRequest::PromptText(_) => {
let reply = call_frontend(host_context, event).await;
Some(reply.unwrap_or(InternalEventPayload::PromptTextResponse(
PromptTextResponse { value: None },
)))
}
// A form streams: the tab sends a response per interaction and the
// plugin re-renders, until one comes back marked done.
HostRequest::PromptForm(_) => {
host_context.events.emit("plugin_event", event);
if event.reply_id.is_none() {
spawn_form_reply_pump(host_context, event, plugin_handle);
}
None
}
HostRequest::ListOpenWorkspaces(_) => {
let workspaces = match host_context.query_manager.connect().list_workspaces() {
Ok(workspaces) => workspaces
.into_iter()
.map(|w| WorkspaceInfo {
id: w.id.clone(),
name: w.name,
label: session.label.clone(),
})
.collect(),
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to list workspaces in bridge: {err}"),
}));
}
};
Some(InternalEventPayload::ListOpenWorkspacesResponse(ListOpenWorkspacesResponse {
workspaces,
}))
}
HostRequest::SendHttpRequest(req) => {
let mut http_request = req.http_request.clone();
if http_request.workspace_id.is_empty() {
let Some(workspace_id) = shared_workspace_id.clone() else {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: "workspace_id is required to send HTTP requests in bridge"
.to_string(),
}));
};
http_request.workspace_id = workspace_id;
}
let cookie_jar_id = match session.cookie_jar_id() {
Some(id) => Some(id),
None => match host_context
.query_manager
.connect()
.list_cookie_jars(http_request.workspace_id.as_str())
{
Ok(jars) => {
jars.into_iter().min_by_key(|jar| jar.created_at).map(|jar| jar.id)
}
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to list cookie jars in bridge: {err}"),
}));
}
},
};
let plugin_context = PluginContext {
workspace_id: Some(http_request.workspace_id.clone()),
..event.context.clone()
};
match send_http_request_with_plugins(SendHttpRequestWithPluginsParams {
query_manager: &host_context.query_manager,
blob_manager: &host_context.blob_manager,
request: http_request,
environment_id: session.environment_id().as_deref(),
update_source: UpdateSource::Plugin,
cookie_jar_id,
response_dir: &host_context.response_dir,
emit_events_to: None,
emit_response_body_chunks_to: None,
existing_response: None,
plugin_manager: host_context.plugin_manager.clone(),
encryption_manager: host_context.encryption_manager.clone(),
plugin_context: &plugin_context,
cancelled_rx: None,
connection_manager: &host_context.connection_manager,
})
.await
{
Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse(
SendHttpRequestResponse { http_response: result.response },
)),
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to send HTTP request in bridge: {err}"),
})),
}
}
HostRequest::RenderHttpRequest(req) => {
let mut http_request = req.http_request.clone();
if http_request.workspace_id.is_empty() {
let Some(workspace_id) = shared_workspace_id.clone() else {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: "workspace_id is required to render HTTP requests in bridge"
.to_string(),
}));
};
http_request.workspace_id = workspace_id;
}
let plugin_context = PluginContext {
workspace_id: Some(http_request.workspace_id.clone()),
..event.context.clone()
};
let environment_chain = match host_context.query_manager.connect().resolve_environments(
&http_request.workspace_id,
http_request.folder_id.as_deref(),
session.environment_id().as_deref(),
) {
Ok(chain) => chain,
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to resolve environments in bridge: {err}"),
}));
}
};
let template_callback = PluginTemplateCallback::new(
host_context.plugin_manager.clone(),
host_context.encryption_manager.clone(),
&plugin_context,
req.purpose.clone(),
);
match render_http_request(
&http_request,
environment_chain,
&template_callback,
&RenderOptions::throw(),
)
.await
{
Ok(http_request) => Some(InternalEventPayload::RenderHttpRequestResponse(
RenderHttpRequestResponse { http_request },
)),
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to render HTTP request in bridge: {err}"),
})),
}
}
HostRequest::RenderGrpcRequest(req) => {
let mut grpc_request = req.grpc_request.clone();
if grpc_request.workspace_id.is_empty() {
let Some(workspace_id) = shared_workspace_id.clone() else {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: "workspace_id is required to render gRPC requests in bridge"
.to_string(),
}));
};
grpc_request.workspace_id = workspace_id;
}
let plugin_context = PluginContext {
workspace_id: Some(grpc_request.workspace_id.clone()),
..event.context.clone()
};
let environment_chain = match host_context.query_manager.connect().resolve_environments(
&grpc_request.workspace_id,
grpc_request.folder_id.as_deref(),
session.environment_id().as_deref(),
) {
Ok(chain) => chain,
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to resolve environments in bridge: {err}"),
}));
}
};
let template_callback = PluginTemplateCallback::new(
host_context.plugin_manager.clone(),
host_context.encryption_manager.clone(),
&plugin_context,
req.purpose.clone(),
);
match render_grpc_request(
&grpc_request,
environment_chain,
&template_callback,
&RenderOptions::throw(),
)
.await
{
Ok(grpc_request) => Some(InternalEventPayload::RenderGrpcRequestResponse(
RenderGrpcRequestResponse { grpc_request },
)),
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to render gRPC request in bridge: {err}"),
})),
}
}
HostRequest::TemplateRender(req) => {
let Some(workspace_id) = shared_workspace_id.clone() else {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: "workspace_id is required to render templates in bridge".to_string(),
}));
};
let plugin_context =
PluginContext { workspace_id: Some(workspace_id.clone()), ..event.context.clone() };
let folder_id = session.request_id().and_then(|rid| {
match host_context.query_manager.connect().get_any_request(&rid) {
Ok(AnyRequest::HttpRequest(r)) => r.folder_id,
Ok(AnyRequest::GrpcRequest(r)) => r.folder_id,
Ok(AnyRequest::WebsocketRequest(r)) => r.folder_id,
Err(_) => None,
}
});
let environment_chain = match host_context.query_manager.connect().resolve_environments(
&workspace_id,
folder_id.as_deref(),
session.environment_id().as_deref(),
) {
Ok(chain) => chain,
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to resolve environments in bridge: {err}"),
}));
}
};
let template_callback = PluginTemplateCallback::new(
host_context.plugin_manager.clone(),
host_context.encryption_manager.clone(),
&plugin_context,
req.purpose.clone(),
);
match render_json_value(
req.data.clone(),
environment_chain,
&template_callback,
&RenderOptions::throw(),
)
.await
{
Ok(data) => {
Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse {
data,
}))
}
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to render template data in bridge: {err}"),
})),
}
}
HostRequest::ListCookieNames(_) => {
let Some(cookie_jar_id) = session.cookie_jar_id() else {
return Some(InternalEventPayload::ListCookieNamesResponse(
ListCookieNamesResponse { names: Vec::new() },
));
};
match host_context.query_manager.connect().get_cookie_jar(&cookie_jar_id) {
Ok(jar) => Some(InternalEventPayload::ListCookieNamesResponse(
ListCookieNamesResponse {
names: jar.cookies.into_iter().map(|c| c.name).collect(),
},
)),
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to load cookie jar in bridge: {err}"),
})),
}
}
HostRequest::GetCookieValue(req) => {
let Some(cookie_jar_id) = session.cookie_jar_id() else {
return Some(InternalEventPayload::GetCookieValueResponse(
GetCookieValueResponse { value: None },
));
};
match host_context.query_manager.connect().get_cookie_jar(&cookie_jar_id) {
Ok(jar) => {
let value =
get_cookie_value_from_jar(jar.cookies, &req.name, req.domain.as_deref());
Some(InternalEventPayload::GetCookieValueResponse(GetCookieValueResponse {
value,
}))
}
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to load cookie jar in bridge: {err}"),
})),
}
}
HostRequest::WindowInfo(req) => {
Some(InternalEventPayload::WindowInfoResponse(WindowInfoResponse {
label: req.label.clone(),
request_id: session.request_id(),
workspace_id: shared_workspace_id.clone(),
environment_id: session.environment_id(),
}))
}
// A tab is one window. Opening and closing them needs the
// multiWindow capability the bridge reports false.
HostRequest::OpenWindow(_) => Some(unsupported("open_window_request")),
HostRequest::CloseWindow(_) => Some(unsupported("close_window_request")),
HostRequest::OtherRequest(payload) => Some(unsupported(&payload.type_name())),
},
}
}
fn unsupported(type_name: &str) -> InternalEventPayload {
InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Unsupported plugin request in bridge: {type_name}"),
})
}
/// Ask the tab and wait for its answer, keyed by the event's id — the same
/// contract as the desktop's `call_frontend`.
async fn call_frontend(
host_context: &BridgeHostContext,
event: &InternalEvent,
) -> Option<InternalEventPayload> {
// Subscribe before emitting: the tab can answer faster than this task is
// rescheduled, and a reply that arrives before the listener exists is lost.
let mut replies = host_context.events.subscribe_inbound(event.id.clone());
host_context.events.emit("plugin_event", event);
let value = replies.recv().await?;
match serde_json::from_value::<InternalEvent>(value) {
Ok(reply) => Some(reply.payload),
Err(e) => {
log::warn!("Failed to parse plugin reply from browser: {e}");
None
}
}
}
/// Forward every form response the tab sends back to the plugin, until one is
/// marked done.
fn spawn_form_reply_pump(
host_context: &BridgeHostContext,
event: &InternalEvent,
plugin_handle: &PluginHandle,
) {
let mut replies = host_context.events.subscribe_inbound(event.id.clone());
let plugin_handle = plugin_handle.clone();
let plugin_context = event.context.clone();
tokio::spawn(async move {
while let Some(value) = replies.recv().await {
let Ok(resp) = serde_json::from_value::<InternalEvent>(value) else {
log::warn!("Failed to parse form response from browser");
continue;
};
let is_done = matches!(
&resp.payload,
InternalEventPayload::PromptFormResponse(r) if r.done.unwrap_or(false)
);
let event_to_send = plugin_handle.build_event_to_send(
&plugin_context,
&resp.payload,
Some(resp.reply_id.unwrap_or_default()),
);
if let Err(e) = plugin_handle.send(&event_to_send).await {
log::warn!("Failed to forward form response to plugin: {e:?}");
}
if is_done {
break;
}
}
});
}
async fn render_json_value<T: TemplateCallback>(
value: Value,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<Value> {
let vars = &make_vars_hashmap(environment_chain);
render_json_value_raw(value, vars, cb, opt).await
}
File diff suppressed because it is too large Load Diff
-147
View File
@@ -1,147 +0,0 @@
//! The bridge's RPC surface.
//!
//! Same envelope and same command names as the desktop, dispatched through the
//! same `RpcRouter`. Only the adapters differ: the desktop's take a Tauri
//! window and read the workspace off its URL, while these take a `BridgeCtx`
//! carrying the connected tab's reported URL. The bodies underneath call the
//! same engine functions in `yaak`, `yaak-models` and `yaak-plugins`.
//!
//! This is a subset — enough to boot, edit, send and inspect. Anything not
//! registered here still gets a well-formed answer: `unsupported_command`
//! turns it into an RPC error naming the command and this host, so the frontend
//! surfaces "not supported by the Yaak Bridge" instead of a bare failure.
mod commands;
pub use commands::implemented_commands;
use crate::session::SessionContext;
use crate::state::BridgeState;
use std::sync::Arc;
use yaak_plugins::events::PluginContext;
use yaak_rpc::{RpcError, RpcRouter};
/// Per-call context. The tab's identity and location, plus the engine.
///
/// Mirrors the desktop's `ClientCtx { window }`: the window there answers both
/// "who is calling" and "what are they looking at", and those are exactly the
/// two things a bridge call needs that the payload doesn't carry.
#[derive(Clone)]
pub struct BridgeCtx {
pub state: Arc<BridgeState>,
pub session: SessionContext,
}
impl BridgeCtx {
pub fn plugin_context(&self) -> PluginContext {
PluginContext::new(Some(self.session.label.clone()), self.session.workspace_id())
}
pub fn update_source(&self) -> yaak_models::util::UpdateSource {
yaak_models::util::UpdateSource::from_window_label(&self.session.label)
}
/// The plugin runtime, or an error naming the reason it isn't there.
pub fn plugins(&self) -> Result<Arc<yaak_plugins::manager::PluginManager>, RpcError> {
self.state.plugin_manager().ok_or_else(|| RpcError {
message: "The plugin runtime failed to start, so this command is unavailable"
.to_string(),
})
}
}
pub fn build_router() -> RpcRouter<BridgeCtx> {
commands::build_router()
}
/// Every command the desktop has that the bridge does not implement.
///
/// Registered explicitly rather than left to fall through to "unknown command",
/// so the message says *why* — the frontend can tell a host that will never
/// support git from one that is simply out of date.
pub const UNSUPPORTED_COMMANDS: &[&str] = &[
// Multi-window. A tab is one window; Settings opens through this on the
// desktop and is therefore unreachable in the browser today.
"cmd_new_child_window",
"cmd_new_main_window",
// gRPC and WebSocket sending.
"cmd_grpc_reflect",
"cmd_grpc_go",
"cmd_grpc_request_actions",
"cmd_call_grpc_request_action",
"cmd_delete_all_grpc_connections",
"cmd_ws_connect",
"cmd_ws_send",
"cmd_ws_close",
"cmd_ws_delete_connections",
"cmd_websocket_request_actions",
"cmd_call_websocket_request_action",
// Git-backed workspaces.
"cmd_git_checkout",
"cmd_git_branch",
"cmd_git_delete_branch",
"cmd_git_delete_remote_branch",
"cmd_git_merge_branch",
"cmd_git_rename_branch",
"cmd_git_status",
"cmd_git_branch_info",
"cmd_git_worktree_status",
"cmd_git_log",
"cmd_git_log_for_file",
"cmd_git_file_diff_for_commit",
"cmd_git_initialize",
"cmd_git_clone",
"cmd_git_commit",
"cmd_git_fetch_all",
"cmd_git_push",
"cmd_git_pull",
"cmd_git_pull_force_reset",
"cmd_git_pull_merge",
"cmd_git_add",
"cmd_git_unstage",
"cmd_git_reset_changes",
"cmd_git_restore_files",
"cmd_git_restore_file_from_commit",
"cmd_git_add_credential",
"cmd_git_remotes",
"cmd_git_add_remote",
"cmd_git_rm_remote",
"cmd_git_watch_worktree_status",
// Filesystem sync.
"cmd_sync_calculate",
"cmd_sync_calculate_fs",
"cmd_sync_apply",
"cmd_sync_watch",
// Workspace encryption.
"cmd_enable_encryption",
"cmd_disable_encryption",
"cmd_reveal_workspace_key",
"cmd_set_workspace_key",
// Things that need a local filesystem the tab can point at.
//
// `cmd_http_response_body_path` is how the desktop host turns a response id
// into a file it can open; a tab has nothing to do with the answer and
// fetches `/responses/:id/body` instead.
"cmd_http_response_body_path",
"cmd_export_data",
"cmd_save_response",
"cmd_save_base64_to_binary",
"cmd_plugins_install_from_directory",
// Desktop application management.
"cmd_restart",
"cmd_check_for_updates",
"cmd_dismiss_notification",
"cmd_send_feedback",
"cmd_plugins_search",
"cmd_plugins_install",
"cmd_plugins_uninstall",
"cmd_plugins_updates",
"cmd_plugins_update_all",
"cmd_reload_plugins",
];
pub fn unsupported_command(cmd: &str) -> RpcError {
RpcError {
message: format!("`{cmd}` is not supported on this host (Yaak Bridge)"),
}
}
-140
View File
@@ -1,140 +0,0 @@
//! What the connected tab is currently looking at.
//!
//! The desktop reads workspace, environment, cookie jar and request straight off
//! the window's URL (crates-tauri/yaak-tauri-utils/src/window.rs). A browser tab
//! runs the same router and so has the same URL, but the server cannot see it —
//! so the tab reports it, on connect and whenever it changes, and the same
//! parsing happens here.
//!
//! One session for the whole process: this slice serves a single tab. A second
//! tab overwrites the first's context rather than getting its own.
use std::sync::{Arc, RwLock};
#[derive(Debug, Clone, Default)]
pub struct SessionContext {
/// Identifies the tab, and lands in `UpdateSource::Window { label }` so
/// model-write echo suppression works exactly as it does on the desktop.
pub label: String,
pub url: String,
}
impl SessionContext {
pub fn workspace_id(&self) -> Option<String> {
let rest = self.url.split("/workspaces/").nth(1)?;
let id: String =
rest.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect();
if id.is_empty() { None } else { Some(id) }
}
pub fn request_id(&self) -> Option<String> {
let rest = self.url.split("/requests/").nth(1)?;
let id: String =
rest.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect();
if id.is_empty() { None } else { Some(id) }
}
pub fn environment_id(&self) -> Option<String> {
self.query_param("environment_id")
}
pub fn cookie_jar_id(&self) -> Option<String> {
self.query_param("cookie_jar_id")
}
fn query_param(&self, key: &str) -> Option<String> {
let query = self.url.split('?').nth(1)?;
let value = query.split('&').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
if k != key {
return None;
}
Some(percent_decode(v))
})?;
// The router writes `environment_id=null` when nothing is selected.
// Neither of these is an id, and treating them as one sends a lookup
// for a model that cannot exist.
if value.is_empty() || value == "null" || value == "undefined" {
return None;
}
Some(value)
}
}
fn percent_decode(input: &str) -> String {
let bytes = input.replace('+', " ").into_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
out.push(byte);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).to_string()
}
#[derive(Clone, Default)]
pub struct SessionStore {
inner: Arc<RwLock<SessionContext>>,
}
impl SessionStore {
pub fn get(&self) -> SessionContext {
match self.inner.read() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
pub fn set(&self, context: SessionContext) {
let mut guard = match self.inner.write() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
*guard = context;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx(url: &str) -> SessionContext {
SessionContext { label: "tab".into(), url: url.into() }
}
#[test]
fn parses_ids_from_a_router_url() {
let c = ctx(
"http://localhost:1472/workspaces/wk_abc123/requests/rq_def456?environment_id=ev_1&cookie_jar_id=cj_2",
);
assert_eq!(c.workspace_id().as_deref(), Some("wk_abc123"));
assert_eq!(c.request_id().as_deref(), Some("rq_def456"));
assert_eq!(c.environment_id().as_deref(), Some("ev_1"));
assert_eq!(c.cookie_jar_id().as_deref(), Some("cj_2"));
}
#[test]
fn placeholder_query_values_are_not_ids() {
let c = ctx("http://localhost:1472/workspaces/wk_a?environment_id=null&cookie_jar_id=");
assert_eq!(c.environment_id(), None);
assert_eq!(c.cookie_jar_id(), None);
}
#[test]
fn missing_parts_are_none() {
let c = ctx("http://localhost:1472/");
assert_eq!(c.workspace_id(), None);
assert_eq!(c.request_id(), None);
assert_eq!(c.environment_id(), None);
assert_eq!(c.cookie_jar_id(), None);
}
}
-244
View File
@@ -1,244 +0,0 @@
//! The bridge's engine handles, shared by every route.
//!
//! Structurally this is `CliContext` (crates-cli/yaak-cli/src/context.rs) with
//! an event hub bolted on: the same `init_standalone` database, the same
//! `PluginManager` over the same Node sidecar. What differs is that a browser
//! tab is attached, so writes have to be pushed out as they happen instead of
//! the process exiting when a command finishes.
use crate::events::EventHub;
use crate::plugin_events::BridgePluginEventBridge;
use crate::session::SessionStore;
use include_dir::{Dir, include_dir};
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Mutex;
use yaak_crypto::manager::EncryptionManager;
use yaak_http::manager::HttpConnectionManager;
use yaak_models::blob_manager::BlobManager;
use yaak_models::client_db::ClientDb;
use yaak_models::query_manager::QueryManager;
use yaak_plugins::events::PluginContext;
use yaak_plugins::manager::PluginManager;
const EMBEDDED_PLUGIN_RUNTIME: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"
));
static EMBEDDED_VENDORED_PLUGINS: Dir<'_> =
include_dir!("$CARGO_MANIFEST_DIR/../../crates-tauri/yaak-app-client/vendored/plugins");
/// What this host can do, mirroring `PlatformCapabilities` in
/// packages/platform/src/types.ts.
///
/// Reported to the browser rather than hardcoded there, because the honest
/// answer depends on how the bridge was built — these become cargo features as
/// the surface grows, and the tab should not have to guess.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgeCapabilities {
pub grpc: bool,
pub websocket: bool,
pub git: bool,
pub sync: bool,
pub tls_options: bool,
pub cookie_jar: bool,
pub local_files: bool,
pub timeline: bool,
pub multi_window: bool,
pub plugins: bool,
pub encryption: bool,
pub updater: bool,
pub clipboard_read: bool,
pub system_fonts: bool,
pub license: bool,
}
impl BridgeCapabilities {
/// The first slice: real HTTP sending with full fidelity, real plugins, a
/// real cookie jar and timeline. Everything the bridge has no route for is
/// reported false so the UI hides it rather than calling and failing.
fn for_this_build(plugins: bool) -> Self {
Self {
grpc: false,
websocket: false,
git: false,
sync: false,
// The engine does the TLS, so client certs and custom CAs are real.
tls_options: true,
cookie_jar: true,
// The bridge has a filesystem but the tab has no way to pick a path
// on it: there is no dialog implementation on this host.
local_files: false,
timeline: true,
multi_window: false,
plugins,
encryption: false,
updater: false,
clipboard_read: false,
system_fonts: false,
license: false,
}
}
}
pub struct BridgeState {
data_dir: PathBuf,
query_manager: QueryManager,
blob_manager: BlobManager,
pub encryption_manager: Arc<EncryptionManager>,
connection_manager: Arc<HttpConnectionManager>,
plugin_manager: Option<Arc<PluginManager>>,
plugin_event_bridge: Mutex<Option<BridgePluginEventBridge>>,
pub events: EventHub,
pub session: SessionStore,
pub capabilities: BridgeCapabilities,
/// Dev-grade shared secret, minted per process. The seam where OTP pairing
/// and per-session keys will go; deliberately not persisted.
pub token: String,
pub is_dev: bool,
}
impl BridgeState {
pub fn new(data_dir: PathBuf, app_id: &str, token: String, is_dev: bool) -> Self {
let db_path = data_dir.join("db.sqlite");
let blob_path = data_dir.join("blobs.sqlite");
let (query_manager, blob_manager, rx) =
match yaak_models::init_standalone(&db_path, &blob_path) {
Ok(v) => v,
Err(err) => {
eprintln!("Error: Failed to initialize database: {err}");
std::process::exit(1);
}
};
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
let events = EventHub::new();
// A Settings row has to exist before the frontend's first render — the
// singular model atom throws without one. `get_settings` upserts a
// default when it finds nothing, so touching it here is enough.
let _ = query_manager.connect().get_settings();
crate::model_writes::start(&query_manager, rx, events.clone());
Self {
data_dir,
query_manager,
blob_manager,
encryption_manager,
connection_manager: Arc::new(HttpConnectionManager::new()),
plugin_manager: None,
plugin_event_bridge: Mutex::new(None),
events,
session: SessionStore::default(),
capabilities: BridgeCapabilities::for_this_build(false),
token,
is_dev,
}
}
/// Start the Node plugin runtime and the host-request bridge. Mirrors
/// `CliContext::init_plugins`; a failure here is survivable, but sending
/// loses auth and template functions, so the capability flips off.
pub async fn init_plugins(&mut self) {
let vendored_plugin_dir = self.data_dir.join("vendored-plugins");
let installed_plugin_dir = self.data_dir.join("installed-plugins");
let node_bin_path = PathBuf::from("node");
prepare_embedded_vendored_plugins(&vendored_plugin_dir)
.expect("Failed to prepare bundled plugins");
let plugin_runtime_main =
std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| {
prepare_embedded_plugin_runtime(&self.data_dir)
.expect("Failed to prepare embedded plugin runtime")
});
match PluginManager::new(
vendored_plugin_dir,
installed_plugin_dir,
node_bin_path,
plugin_runtime_main,
&self.query_manager,
&PluginContext::new_empty(),
false,
)
.await
{
Ok(plugin_manager) => {
let plugin_manager = Arc::new(plugin_manager);
let plugin_event_bridge = BridgePluginEventBridge::start(
plugin_manager.clone(),
self.query_manager.clone(),
self.blob_manager.clone(),
self.encryption_manager.clone(),
self.connection_manager.clone(),
self.data_dir.clone(),
self.events.clone(),
self.session.clone(),
)
.await;
self.plugin_manager = Some(plugin_manager);
*self.plugin_event_bridge.lock().await = Some(plugin_event_bridge);
self.capabilities.plugins = true;
}
Err(err) => {
log::warn!("Failed to initialize plugins: {err}");
self.capabilities.plugins = false;
}
}
}
pub fn data_dir(&self) -> &Path {
&self.data_dir
}
pub fn response_dir(&self) -> PathBuf {
self.data_dir.join("responses")
}
pub fn db(&self) -> ClientDb<'_> {
self.query_manager.connect()
}
pub fn query_manager(&self) -> &QueryManager {
&self.query_manager
}
pub fn blob_manager(&self) -> &BlobManager {
&self.blob_manager
}
pub fn connection_manager(&self) -> &HttpConnectionManager {
&self.connection_manager
}
pub fn plugin_manager(&self) -> Option<Arc<PluginManager>> {
self.plugin_manager.clone()
}
pub async fn shutdown(&self) {
if let Some(plugin_manager) = &self.plugin_manager {
if let Some(plugin_event_bridge) = self.plugin_event_bridge.lock().await.take() {
plugin_event_bridge.shutdown(plugin_manager).await;
}
plugin_manager.terminate().await;
}
}
}
fn prepare_embedded_plugin_runtime(data_dir: &Path) -> std::io::Result<PathBuf> {
let runtime_dir = data_dir.join("vendored").join("plugin-runtime");
fs::create_dir_all(&runtime_dir)?;
let runtime_main = runtime_dir.join("index.cjs");
fs::write(&runtime_main, EMBEDDED_PLUGIN_RUNTIME)?;
Ok(runtime_main)
}
fn prepare_embedded_vendored_plugins(vendored_plugin_dir: &Path) -> std::io::Result<()> {
fs::create_dir_all(vendored_plugin_dir)?;
EMBEDDED_VENDORED_PLUGINS.extract(vendored_plugin_dir)?;
Ok(())
}
File diff suppressed because one or more lines are too long
+16 -75
View File
@@ -30,7 +30,6 @@ use tokio::sync::Mutex;
use tokio::task::block_in_place;
use tokio::time;
use yaak::export::{self, ExportDataParams};
use yaak::responses::{is_response_id, response_body_path};
use yaak_common::command::new_checked_command;
use yaak_crypto::manager::EncryptionManager;
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
@@ -1021,64 +1020,27 @@ async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
}
}
/// Where a response's body is, and what it is meant to be read as.
struct ResponseBodyLocation {
/// None when the response has no stored body.
path: Option<PathBuf>,
/// The response's declared `Content-Type`, empty when it has none. An
/// ephemeral response has no headers to consult, so its body decodes as
/// UTF-8.
content_type: String,
}
/// Find a response's body from its id alone.
///
/// The frontend hands back an id and never a path, so nothing a caller says can
/// point this at a file the engine didn't write. Persisted responses carry the
/// path they were written to; ephemeral ones (GraphQL introspection) never
/// reach the database, but their body still lands at `<response dir>/<id>`,
/// which is how [`yaak::send`] builds the path in the first place.
fn locate_response_body<R: Runtime>(
app_handle: &AppHandle<R>,
response_id: &str,
) -> YaakResult<ResponseBodyLocation> {
if !is_response_id(response_id) {
return Err(GenericError(format!("Invalid response ID {response_id}")));
}
match app_handle.db().get_http_response(response_id) {
Ok(response) => Ok(ResponseBodyLocation {
path: response.body_path.map(PathBuf::from),
content_type: response
.headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
.map(|h| h.value.clone())
.unwrap_or_default(),
}),
Err(yaak_models::error::Error::ModelNotFound(_)) => {
let response_dir = app_handle.path().app_data_dir()?.join("responses");
Ok(ResponseBodyLocation {
path: Some(response_body_path(&response_dir, response_id)),
content_type: String::new(),
})
}
Err(e) => Err(e.into()),
}
}
async fn cmd_http_response_body<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
response_id: &str,
response: HttpResponse,
filter: Option<&str>,
) -> YaakResult<FilterResponse> {
let location = locate_response_body(window.app_handle(), response_id)?;
let Some(body_path) = location.path else {
return Ok(FilterResponse { content: String::new(), error: None });
let body_path = match response.body_path {
None => {
return Ok(FilterResponse { content: String::new(), error: None });
}
Some(p) => p,
};
let content_type = location.content_type.as_str();
let content_type = response
.headers
.iter()
.find_map(|h| {
if h.name.eq_ignore_ascii_case("content-type") { Some(h.value.as_str()) } else { None }
})
.unwrap_or_default();
let body = read_response_body(&body_path, content_type)
.await
.ok_or(GenericError("Failed to find response body".to_string()))?;
@@ -1091,20 +1053,6 @@ async fn cmd_http_response_body<R: Runtime>(
}
}
/// The body's path on this machine, for the desktop host to read or hand to the
/// webview's asset protocol.
///
/// The frontend holds response ids; only `packages/platform`'s Tauri host sees
/// the path, and only because it is about to open the file itself. Hosts
/// without a filesystem serve the same bytes over HTTP instead.
async fn cmd_http_response_body_path<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
) -> YaakResult<Option<String>> {
let location = locate_response_body(&app_handle, response_id)?;
Ok(location.path.map(|p| p.to_string_lossy().to_string()))
}
async fn cmd_http_request_body<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
@@ -1121,15 +1069,8 @@ async fn cmd_http_request_body<R: Runtime>(
Ok(Some(body))
}
async fn cmd_get_sse_events<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
) -> YaakResult<Vec<ServerSentEvent>> {
let Some(body_path) = locate_response_body(&app_handle, response_id)?.path else {
return Ok(Vec::new());
};
let body = fs::read(body_path)?;
async fn cmd_get_sse_events(file_path: &str) -> YaakResult<Vec<ServerSentEvent>> {
let body = fs::read(file_path)?;
let mut event_parser = EventParser::new();
event_parser.process_bytes(body.into())?;
+56 -3
View File
@@ -144,10 +144,28 @@ pub(crate) fn models_upsert<R: Runtime>(
window: WebviewWindow<R>,
model: AnyModel,
) -> Result<String> {
use yaak_models::error::Error::GenericError;
let db = window.db();
let blobs = window.blob_manager();
let source = &UpdateSource::from_window_label(window.label());
yaak::models_ops::upsert_model(&db, &blobs, model, source)
let id = match model {
AnyModel::CookieJar(m) => db.upsert_cookie_jar(&m, source)?.id,
AnyModel::Environment(m) => db.upsert_environment(&m, source)?.id,
AnyModel::Folder(m) => db.upsert_folder(&m, source)?.id,
AnyModel::GrpcRequest(m) => db.upsert_grpc_request(&m, source)?.id,
AnyModel::HttpRequest(m) => db.upsert_http_request(&m, source)?.id,
AnyModel::HttpResponse(m) => db.upsert_http_response(&m, source, &blobs)?.id,
AnyModel::KeyValue(m) => db.upsert_key_value(&m, source)?.id,
AnyModel::Plugin(m) => db.upsert_plugin(&m, source)?.id,
AnyModel::Settings(m) => db.upsert_settings(&m, source)?.id,
AnyModel::WebsocketRequest(m) => db.upsert_websocket_request(&m, source)?.id,
AnyModel::Workspace(m) => db.upsert_workspace(&m, source)?.id,
AnyModel::WorkspaceMeta(m) => db.upsert_workspace_meta(&m, source)?.id,
a => return Err(GenericError(format!("Cannot upsert AnyModel {a:?})"))),
};
Ok(id)
}
// Async so cascading deletes (e.g. a workspace with thousands of requests) run on a
@@ -163,7 +181,21 @@ pub(crate) async fn models_delete<R: Runtime>(
// Use transaction for deletions because it might recurse
window.with_tx(|tx| {
let source = &UpdateSource::from_window_label(window.label());
yaak::models_ops::delete_model(tx, &blobs, model, source)
let id = match model {
AnyModel::CookieJar(m) => tx.delete_cookie_jar(&m, source)?.id,
AnyModel::Environment(m) => tx.delete_environment(&m, source)?.id,
AnyModel::Folder(m) => tx.delete_folder(&m, source)?.id,
AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id,
AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id,
AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id,
AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, &blobs)?.id,
AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id,
AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id,
AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id,
AnyModel::Workspace(m) => tx.delete_workspace(&m, source, &blobs)?.id,
a => return Err(GenericError(format!("Cannot delete AnyModel {a:?})"))),
};
Ok(id)
})
})
.await
@@ -175,10 +207,31 @@ pub(crate) fn models_duplicate<R: Runtime>(
model_type: String,
model_id: String,
) -> Result<String> {
use yaak_models::error::Error::GenericError;
// Use transaction for duplications because it might recurse
window.with_tx(|tx| {
let source = &UpdateSource::from_window_label(window.label());
yaak::models_ops::duplicate_model(tx, &model_type, &model_id, source)
// Fetch the model fresh from the DB so the duplicate doesn't come from
// a stale frontend snapshot
let id = match model_type.as_str() {
"environment" => {
tx.duplicate_environment(&tx.get_environment(&model_id)?, source)?.id
}
"folder" => tx.duplicate_folder(&tx.get_folder(&model_id)?, source)?.id,
"grpc_request" => {
tx.duplicate_grpc_request(&tx.get_grpc_request(&model_id)?, source)?.id
}
"http_request" => {
tx.duplicate_http_request(&tx.get_http_request(&model_id)?, source)?.id
}
"websocket_request" => {
tx.duplicate_websocket_request(&tx.get_websocket_request(&model_id)?, source)?.id
}
t => return Err(GenericError(format!("Cannot duplicate model type {t}"))),
};
Ok(id)
})
}
+5 -17
View File
@@ -268,17 +268,10 @@ pub(crate) struct CmdFormatGraphqlReq {
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub(crate) struct CmdHttpResponseBodyReq {
pub response_id: String,
pub response: HttpResponse,
pub filter: Option<String>,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub(crate) struct CmdHttpResponseBodyPathReq {
pub response_id: String,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
@@ -290,7 +283,7 @@ pub(crate) struct CmdHttpRequestBodyReq {
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub(crate) struct CmdGetSseEventsReq {
pub response_id: String,
pub file_path: String,
}
#[derive(Debug, Deserialize, TS)]
@@ -990,19 +983,15 @@ 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(), 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>> {
Ok(crate::cmd_http_response_body_path(ctx.window.app_handle().clone(), &req.response_id).await?)
Ok(crate::cmd_http_response_body(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), req.response, req.filter.as_deref()).await?)
}
async fn cmd_http_request_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestBodyReq) -> Result<Option<Vec<u8>>> {
Ok(crate::cmd_http_request_body(ctx.window.app_handle().clone(), &req.response_id).await?)
}
async fn cmd_get_sse_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetSseEventsReq) -> Result<Vec<ServerSentEvent>> {
Ok(crate::cmd_get_sse_events(ctx.window.app_handle().clone(), &req.response_id).await?)
async fn cmd_get_sse_events<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGetSseEventsReq) -> Result<Vec<ServerSentEvent>> {
Ok(crate::cmd_get_sse_events(&req.file_path).await?)
}
async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpResponseEventsReq) -> Result<Vec<HttpResponseEvent>> {
@@ -1382,7 +1371,6 @@ rpc_commands! {
cmd_format_json(CmdFormatJsonReq) -> String,
cmd_format_graphql(CmdFormatGraphqlReq) -> String,
cmd_http_response_body(CmdHttpResponseBodyReq) -> FilterResponse,
cmd_http_response_body_path(CmdHttpResponseBodyPathReq) -> Option<String>,
cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>,
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
-2
View File
@@ -1,10 +1,8 @@
pub mod error;
pub mod export;
pub mod import;
pub mod models_ops;
pub mod plugin_events;
pub mod render;
pub mod responses;
pub mod send;
pub use error::Error;
-89
View File
@@ -1,89 +0,0 @@
//! Generic model writes, shared by every host.
//!
//! `upsert`, `delete` and `duplicate` take an `AnyModel` and fan out to the
//! typed query for its variant. That fan-out is long, mechanical, and has to
//! grow a new arm every time a model is added — exactly the code that should
//! not exist twice. The host supplies the database handles and the
//! `UpdateSource` identifying who is writing; nothing here knows whether the
//! caller is a desktop window or an HTTP request.
use yaak_models::blob_manager::BlobManager;
use yaak_models::client_db::ClientDb;
use yaak_models::error::Error::GenericError;
use yaak_models::error::Result;
use yaak_models::models::AnyModel;
use yaak_models::util::UpdateSource;
pub fn upsert_model(
db: &ClientDb,
blobs: &BlobManager,
model: AnyModel,
source: &UpdateSource,
) -> Result<String> {
let id = match model {
AnyModel::CookieJar(m) => db.upsert_cookie_jar(&m, source)?.id,
AnyModel::Environment(m) => db.upsert_environment(&m, source)?.id,
AnyModel::Folder(m) => db.upsert_folder(&m, source)?.id,
AnyModel::GrpcRequest(m) => db.upsert_grpc_request(&m, source)?.id,
AnyModel::HttpRequest(m) => db.upsert_http_request(&m, source)?.id,
AnyModel::HttpResponse(m) => db.upsert_http_response(&m, source, blobs)?.id,
AnyModel::KeyValue(m) => db.upsert_key_value(&m, source)?.id,
AnyModel::Plugin(m) => db.upsert_plugin(&m, source)?.id,
AnyModel::Settings(m) => db.upsert_settings(&m, source)?.id,
AnyModel::WebsocketRequest(m) => db.upsert_websocket_request(&m, source)?.id,
AnyModel::Workspace(m) => db.upsert_workspace(&m, source)?.id,
AnyModel::WorkspaceMeta(m) => db.upsert_workspace_meta(&m, source)?.id,
a => return Err(GenericError(format!("Cannot upsert AnyModel {a:?})"))),
};
Ok(id)
}
/// Deletes cascade, so callers run this inside a transaction.
pub fn delete_model(
tx: &ClientDb,
blobs: &BlobManager,
model: AnyModel,
source: &UpdateSource,
) -> Result<String> {
let id = match model {
AnyModel::CookieJar(m) => tx.delete_cookie_jar(&m, source)?.id,
AnyModel::Environment(m) => tx.delete_environment(&m, source)?.id,
AnyModel::Folder(m) => tx.delete_folder(&m, source)?.id,
AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id,
AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id,
AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id,
AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, blobs)?.id,
AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id,
AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id,
AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id,
AnyModel::Workspace(m) => tx.delete_workspace(&m, source, blobs)?.id,
a => return Err(GenericError(format!("Cannot delete AnyModel {a:?})"))),
};
Ok(id)
}
/// Duplicates recurse, so callers run this inside a transaction.
///
/// The model is re-read from the database rather than taken from the caller, so
/// a duplicate never comes from a stale frontend snapshot.
pub fn duplicate_model(
tx: &ClientDb,
model_type: &str,
model_id: &str,
source: &UpdateSource,
) -> Result<String> {
let id = match model_type {
"environment" => tx.duplicate_environment(&tx.get_environment(model_id)?, source)?.id,
"folder" => tx.duplicate_folder(&tx.get_folder(model_id)?, source)?.id,
"grpc_request" => tx.duplicate_grpc_request(&tx.get_grpc_request(model_id)?, source)?.id,
"http_request" => tx.duplicate_http_request(&tx.get_http_request(model_id)?, source)?.id,
"websocket_request" => {
tx.duplicate_websocket_request(&tx.get_websocket_request(model_id)?, source)?.id
}
t => return Err(GenericError(format!("Cannot duplicate model type {t}"))),
};
Ok(id)
}
-53
View File
@@ -1,53 +0,0 @@
//! Where response bodies live, from a response id alone.
//!
//! [`send`](crate::send) writes each body to `<response dir>/<response id>`,
//! and every reader has to find it again knowing only the id: commands take an
//! id from the client and never a path, so a path the client chose can never be
//! opened. [`is_response_id`] is the check that makes joining a client-supplied
//! id onto the response directory safe.
use std::path::{Path, PathBuf};
/// The file a response's body is written to, and therefore read back from.
pub fn response_body_path(response_dir: &Path, response_id: &str) -> PathBuf {
response_dir.join(response_id)
}
/// Whether a string is shaped like a response id, and can therefore be joined
/// onto the response directory without escaping it.
///
/// Ids are `rs_<hex>`. Rejecting anything else outright, rather than trying to
/// sanitize it, is what keeps "read the body of response X" from naming a file
/// of the caller's choosing: no separators, no `..`, no absolute paths, no
/// drive letters, no NUL.
pub fn is_response_id(id: &str) -> bool {
!id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_generated_ids() {
assert!(is_response_id("rs_A1b2C3d4"));
assert!(is_response_id("rs_with-dash"));
}
#[test]
fn rejects_anything_that_could_escape_the_directory() {
for id in [
"",
"..",
"../../etc/passwd",
"rs_1/../../secret",
"/etc/passwd",
r"C:\Windows\System32",
r"rs_1\..\secret",
"rs_1.txt",
"rs_1\0",
] {
assert!(!is_response_id(id), "{id:?} should be rejected");
}
}
}
+1 -2
View File
@@ -1,5 +1,4 @@
use crate::render::render_http_request;
use crate::responses::response_body_path;
use async_trait::async_trait;
use log::warn;
use std::path::{Path, PathBuf};
@@ -661,7 +660,7 @@ pub async fn send_http_request<T: TemplateCallback>(
source,
}
})?;
let body_path = response_body_path(params.response_dir, &response.id);
let body_path = params.response_dir.join(&response.id);
let response_body_path = body_path.to_string_lossy().to_string();
let connected_response = HttpResponse {
state: HttpResponseState::Connected,
-211
View File
@@ -1,211 +0,0 @@
import type { Unsubscribe } from "../types";
/**
* The wire to the Yaak Bridge: one `POST /rpc` per command, one WebSocket for
* events in both directions.
*
* The hard requirement this file exists to satisfy: the connection is opened
* asynchronously, but the host that uses it must be constructible
* *synchronously*. Boot-time modules call commands while the module graph is
* still evaluating (`lib/appInfo.ts` top-level-awaits one), so there is no
* later moment to install a host, and a registry that waited for a socket would
* deadlock. So every call made before the connection opens is queued here and
* flushed when it does. The app's own top-level await then doubles as the
* connection gate: nothing renders until the first command has answered, which
* means it has answered over a live connection.
*/
interface EventFrame {
event: string;
payload: unknown;
}
export interface BridgeInfo {
name: string;
version: string;
capabilities: Record<string, boolean>;
commands: string[];
}
/** How long to wait before retrying a dropped connection, and the ceiling. */
const RECONNECT_BASE_MS = 250;
const RECONNECT_MAX_MS = 5000;
export class BridgeConnection {
readonly baseUrl: string;
readonly label: string;
/**
* Null when the user hasn't supplied one yet. The connection then never
* opens, so every call queues forever — which is exactly what the connect
* screen wants, and means "waiting for a token" and "waiting for the socket"
* are the same code path rather than two.
*/
private readonly token: string | null;
private socket: WebSocket | null = null;
private connected = false;
private reconnectDelay = RECONNECT_BASE_MS;
/** Frames the page tried to send before the socket opened. */
private outboundQueue: EventFrame[] = [];
/** Resolvers for anything awaiting the first successful connection. */
private readyWaiters: Array<() => void> = [];
private listeners = new Map<string, Set<(payload: unknown) => void>>();
info: BridgeInfo | null = null;
constructor(baseUrl: string, token: string | null, label: string) {
this.baseUrl = baseUrl.replace(/\/$/, "");
this.token = token;
this.label = label;
if (token != null) this.openSocket();
}
get hasToken(): boolean {
return this.token != null;
}
/** Resolves once the events socket is open. */
ready(): Promise<void> {
if (this.connected) return Promise.resolve();
return new Promise((resolve) => this.readyWaiters.push(resolve));
}
/** A URL on the bridge with the token attached, for the browser to fetch directly. */
url(path: string): string {
const url = new URL(this.baseUrl + path);
url.searchParams.set("token", this.token ?? "");
return url.toString();
}
async fetch(path: string, init?: RequestInit): Promise<Response> {
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${this.token ?? ""}`);
return fetch(this.baseUrl + path, { ...init, headers });
}
async loadInfo(): Promise<BridgeInfo> {
const res = await this.fetch("/bridge/info");
if (!res.ok) {
throw new Error(`Bridge rejected the connection (${res.status}). Is the token correct?`);
}
this.info = (await res.json()) as BridgeInfo;
return this.info;
}
/**
* Send a command and await its result.
*
* Waits for the connection first, so a command issued during module
* evaluation queues instead of failing. Errors are carried inside the
* envelope and rethrown here, so callers see the backend's own message —
* matching what Tauri's `invoke` does with a rejected command.
*/
async rpc<T>(cmd: string, payload: Record<string, unknown> = {}): Promise<T> {
await this.ready();
const res = await this.fetch("/rpc", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: crypto.randomUUID(), cmd, payload }),
});
if (!res.ok) {
throw new Error(`Bridge request failed (${res.status})`);
}
const body = (await res.json()) as
| { type: "Success"; id: string; payload: T }
| { type: "Error"; id: string; error: string };
if (body.type === "Error") {
throw new Error(body.error);
}
return body.payload;
}
listen(event: string, callback: (payload: unknown) => void): Unsubscribe {
let handlers = this.listeners.get(event);
if (handlers == null) {
handlers = new Set();
this.listeners.set(event, handlers);
}
handlers.add(callback);
// Synchronous, because callers unsubscribe from React cleanups.
return () => {
const current = this.listeners.get(event);
if (current == null) return;
current.delete(callback);
if (current.size === 0) this.listeners.delete(event);
};
}
emit(event: string, payload: unknown): void {
const frame: EventFrame = { event, payload };
if (this.socket != null && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(frame));
} else {
this.outboundQueue.push(frame);
}
}
/** Tell the bridge who and where we are — what a desktop window's URL says. */
attach(): void {
this.emit("bridge_attach", { label: this.label, url: window.location.href });
}
private openSocket(): void {
const wsUrl = new URL(this.baseUrl.replace(/^http/, "ws") + "/events");
wsUrl.searchParams.set("token", this.token ?? "");
const socket = new WebSocket(wsUrl.toString());
this.socket = socket;
socket.onopen = () => {
this.connected = true;
this.reconnectDelay = RECONNECT_BASE_MS;
this.attach();
for (const frame of this.outboundQueue.splice(0)) {
socket.send(JSON.stringify(frame));
}
for (const resolve of this.readyWaiters.splice(0)) {
resolve();
}
};
socket.onmessage = (message) => {
let frame: EventFrame;
try {
frame = JSON.parse(String(message.data)) as EventFrame;
} catch {
console.warn("Bridge sent a malformed event frame");
return;
}
// Deliver the payload directly, not wrapped in Tauri's `{ payload }`.
for (const handler of this.listeners.get(frame.event) ?? []) {
try {
handler(frame.payload);
} catch (err) {
console.error("Bridge event handler threw", frame.event, err);
}
}
};
socket.onclose = () => {
this.connected = false;
this.socket = null;
// The server closes the socket when a tab falls too far behind to be
// consistent, so a reconnect has to re-read the workspace rather than
// resume. `bridge_reconnected` is what tells the app to do that.
window.setTimeout(() => this.openSocket(), this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
};
socket.onerror = () => {
// `onclose` always follows, and it owns the retry.
socket.close();
};
}
}
-297
View File
@@ -1,297 +0,0 @@
import type {
DragDropEvent,
OsType,
Platform,
PlatformCapabilities,
PlatformWindow,
RpcPayload,
RpcStreamHandle,
Unsubscribe,
} from "../types";
import { BridgeConnection } from "./connection";
/**
* The browser host: the Yaak UI in a tab, with the real engine running in the
* Yaak Bridge next to it.
*
* Everything the desktop gets from Tauri comes over one HTTP connection
* instead. The parts a page genuinely cannot do — a native file dialog, a
* second window, reading the clipboard unprompted — are not faked. They report
* false through `capabilities` and throw if called anyway, so a missing feature
* surfaces as a disabled control rather than a silent no-op.
*/
/**
* Until the bridge answers, assume nothing works.
*
* These are replaced wholesale by the server's own report as soon as
* `/bridge/info` returns, which happens before the app's first render — the
* boot sequence top-level-awaits a command, and that command cannot resolve
* before the connection is up. Starting pessimistic means that if that ordering
* ever changes, the UI hides a feature it should have shown instead of offering
* one that will fail.
*/
const NO_CAPABILITIES: PlatformCapabilities = {
grpc: false,
websocket: false,
git: false,
sync: false,
tlsOptions: false,
cookieJar: false,
localFiles: false,
timeline: false,
multiWindow: false,
plugins: false,
encryption: false,
updater: false,
clipboardRead: false,
systemFonts: false,
license: false,
};
function unsupported(what: string): Error {
return new Error(`${what} is not supported in the browser`);
}
/** Match `@tauri-apps/plugin-os` spellings so layout code needs no new branch. */
function detectOsType(): OsType {
const platform = navigator.userAgent;
if (/Mac|iPhone|iPad|iPod/.test(platform)) return "macos";
if (/Win/.test(platform)) return "windows";
if (/Android/.test(platform)) return "android";
return "linux";
}
/**
* The route that serves a response body, keyed by the response id.
*
* The bridge resolves the id against its own database, so this is the only
* thing the page ever has to know about where a body lives.
*/
function responseBodyRoute(responseId: string): string {
return `/responses/${encodeURIComponent(responseId)}/body`;
}
/**
* Answer the Tauri host-plugin commands, which ride outside the RPC envelope.
*
* `set_title` has a real browser equivalent. `set_theme` paints the native
* window frame behind the webview, which a tab has no equivalent of and does
* not need. Everything else is a desktop-only feature; rejecting is correct,
* and the callers already gate on the matching capability.
*/
async function handleHostPluginCommand<T>(cmd: string, payload?: RpcPayload): Promise<T> {
switch (cmd) {
case "plugin:yaak-mac-window|set_title": {
const title = payload?.title;
document.title = typeof title === "string" ? title : "Yaak";
return undefined as T;
}
case "plugin:yaak-mac-window|set_theme":
return undefined as T;
default:
throw unsupported(`\`${cmd}\``);
}
}
function createWindow(connection: BridgeConnection): PlatformWindow {
const noop = async () => {};
return {
label: connection.label,
// A tab manages its own frame. These exist because the interface names
// them; the UI only reaches for them behind `multiWindow`.
show: noop,
close: noop,
minimize: noop,
maximize: noop,
unmaximize: noop,
isMaximized: async () => false,
isFullscreen: async () => document.fullscreenElement != null,
setZoom: noop,
// Null means "no opinion, let CSS decide". The desktop returns a real value
// because applying a theme forces the window appearance and poisons the
// media query; nothing does that here, so `prefers-color-scheme` is the
// honest answer and the theme package already falls back to it.
theme: async () => null,
onThemeChanged(callback) {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const listener = () => callback(media.matches ? "dark" : "light");
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
},
onFocusChanged(callback) {
const onFocus = () => callback(true);
const onBlur = () => callback(false);
window.addEventListener("focus", onFocus);
window.addEventListener("blur", onBlur);
return () => {
window.removeEventListener("focus", onFocus);
window.removeEventListener("blur", onBlur);
};
},
// Native drag-and-drop reports OS paths, which a page never sees. The DOM's
// own drag events are a different thing and the components that need them
// use them directly.
onDragDrop(_callback: (event: DragDropEvent) => void): Unsubscribe {
return () => {};
},
};
}
/**
* Keep the bridge told where the tab is.
*
* The desktop reads the workspace, environment, cookie jar and request straight
* off the window's URL whenever a plugin asks. The bridge can't, so the tab
* pushes it on every navigation. The router uses the History API, which fires
* no event of its own on push, hence the wrapping.
*/
function trackNavigation(connection: BridgeConnection): void {
const report = () => connection.attach();
for (const method of ["pushState", "replaceState"] as const) {
const original = history[method];
history[method] = function (this: History, ...args: Parameters<History["pushState"]>) {
const result = original.apply(this, args);
report();
return result;
};
}
window.addEventListener("popstate", report);
window.addEventListener("hashchange", report);
}
export function createBridgePlatform(baseUrl: string, token: string | null): Platform {
const label = `tab_${crypto.randomUUID().slice(0, 8)}`;
const connection = new BridgeConnection(baseUrl, token, label);
// Mutated in place once the bridge reports, because `platform.capabilities`
// hands out this object and callers hold the reference.
const capabilities: PlatformCapabilities = { ...NO_CAPABILITIES };
if (connection.hasToken) {
void connection
.loadInfo()
.then((info) => Object.assign(capabilities, info.capabilities))
.catch((err) => console.error("Failed to read bridge capabilities", err));
}
trackNavigation(connection);
// Two host requests the plugin runtime makes that only a page can carry out.
connection.listen("bridge_copy_text", (payload) => {
const text = (payload as { text?: string } | null)?.text;
if (typeof text === "string") void navigator.clipboard.writeText(text);
});
connection.listen("bridge_open_url", (payload) => {
const url = (payload as { url?: string } | null)?.url;
if (typeof url === "string") window.open(url, "_blank", "noopener,noreferrer");
});
const platformWindow = createWindow(connection);
return {
capabilities,
window: platformWindow,
clipboard: {
writeText: (text) => navigator.clipboard.writeText(text),
// Reading needs a permission prompt the moment the page paints, which is
// a bad ask for an app people paste bearer tokens into. `clipboardRead`
// is false and the one caller is gated on it.
readText: async () => {
throw unsupported("Reading the clipboard");
},
clear: async () => {
throw unsupported("Clearing the clipboard");
},
},
dialog: {
open: (async () => null) as Platform["dialog"]["open"],
save: async () => null,
},
files: {
readDir: async () => {
throw unsupported("Browsing the filesystem");
},
// App resources are served by the page's own origin, so the path the
// caller resolved is already a URL a tab can load.
url: (path) => path,
basename: async (path) => path.split(/[/\\]/).pop() ?? path,
resolveResource: async (path) => path,
// The route takes the response id and looks the body up itself, so the
// page never names a file — the same reason the desktop asks the backend
// for the path instead of building one.
async readResponseBody(responseId) {
const res = await connection.fetch(responseBodyRoute(responseId));
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(`Failed to read response body (${res.status})`);
}
return new Uint8Array(await res.arrayBuffer());
},
// The `<img src>`/`<video src>` equivalent of Tauri's `convertFileSrc`.
// The token rides in the query because the browser makes these requests
// itself and the page cannot add a header to them.
responseBodyUrl: async (responseId) => connection.url(responseBodyRoute(responseId)),
},
rpc: <T,>(cmd: string, payload?: RpcPayload): Promise<T> => {
// `plugin:`-prefixed commands are Tauri host plugins, not engine
// commands, so they never reach the RpcRouter. Two of them are window
// chrome the tab can do itself; the rest belong to features this host
// reports false for, and saying so beats a confusing "unknown command".
if (cmd.startsWith("plugin:")) {
return handleHostPluginCommand<T>(cmd, payload);
}
return connection.rpc<T>(cmd, payload);
},
async rpcStream<T, M>(
cmd: string,
payload: RpcPayload,
onMessage: (message: M) => void,
): Promise<RpcStreamHandle<T>> {
// Caller-minted id, subscribed before dispatch, exactly as on the
// desktop: the command can emit its first message before it returns.
const streamId = crypto.randomUUID();
const unlisten = connection.listen(`stream_${streamId}`, (p) => onMessage(p as M));
try {
const result = await connection.rpc<T>(cmd, { ...payload, streamId });
return { result, unlisten };
} catch (err) {
unlisten();
throw err;
}
},
listen: <T,>(event: string, callback: (payload: T) => void): Unsubscribe =>
connection.listen(event, (payload) => callback(payload as T)),
emit: async (event, payload) => connection.emit(event, payload),
openUrl: async (url) => {
window.open(url, "_blank", "noopener,noreferrer");
},
revealItemInDir: async () => {
throw unsupported("Revealing a file");
},
osType: detectOsType,
appIdentifier: async () => "app.yaak.bridge",
};
}
-108
View File
@@ -1,108 +0,0 @@
/**
* Deciding which host to install, and getting a bridge token when there isn't
* one yet.
*
* Dev-grade on purpose. The token is a shared secret the bridge prints at
* startup, passed in the URL and kept for the session. OTP pairing and request
* encryption replace this whole file; the seam is that nothing outside it knows
* how the token was obtained.
*/
export interface BridgeConfig {
url: string;
token: string;
}
const TOKEN_STORAGE_KEY = "yaak.bridge.token";
const TOKEN_QUERY_PARAM = "bridgeToken";
declare global {
interface Window {
__TAURI_INTERNALS__?: unknown;
}
// Declared here rather than by depending on Vite's types: this package is
// consumed by a bundler that provides them, and only this one variable.
interface ImportMeta {
readonly env?: Record<string, string | undefined>;
}
}
function bridgeUrl(): string {
// Set when the frontend runs on a Vite dev server and the bridge is on its
// own port. When the bridge serves the built app, they share an origin.
const configured = import.meta.env?.VITE_YAAK_BRIDGE_URL;
return (configured ?? window.location.origin).replace(/\/$/, "");
}
/**
* The bridge token, or null if the user hasn't supplied one.
*
* A token in the URL is consumed and stashed: leaving it in the address bar
* means it lands in the router's own history entries and in anything the user
* copies out of the bar.
*/
function readToken(): string | null {
const url = new URL(window.location.href);
const fromQuery = url.searchParams.get(TOKEN_QUERY_PARAM);
if (fromQuery != null && fromQuery !== "") {
sessionStorage.setItem(TOKEN_STORAGE_KEY, fromQuery);
url.searchParams.delete(TOKEN_QUERY_PARAM);
history.replaceState(null, "", url.toString());
return fromQuery;
}
return sessionStorage.getItem(TOKEN_STORAGE_KEY);
}
/** Whether this build should talk to a bridge at all. */
export function shouldUseBridge(): boolean {
if (typeof window === "undefined") return false;
// Running inside the desktop app: Tauri always wins.
if (window.__TAURI_INTERNALS__ != null) return false;
return true;
}
export function bridgeConfig(): BridgeConfig | null {
const token = readToken();
if (token == null) return null;
return { url: bridgeUrl(), token };
}
/**
* Put the connect form on screen.
*
* Synchronous, and it does not stop anything by itself — the caller pairs it
* with a host that never connects, so the app's own boot-time await is what
* holds. Submitting reloads with the token in the query, which `readToken`
* then consumes.
*/
export function promptForToken(): void {
document.body.innerHTML = `
<div style="font-family: system-ui, sans-serif; max-width: 26rem; margin: 15vh auto; padding: 0 1.5rem; color: #d5d3e0">
<h1 style="font-size: 1.25rem; margin: 0 0 0.5rem">Connect to the Yaak Bridge</h1>
<p style="margin: 0 0 1.25rem; line-height: 1.5; color: #9a97ad">
Paste the token the bridge printed when it started.
</p>
<form id="yaak-bridge-connect" style="display: flex; gap: 0.5rem">
<input name="token" autofocus autocomplete="off" spellcheck="false" placeholder="Bridge token"
style="flex: 1; padding: 0.5rem 0.65rem; border-radius: 0.375rem; border: 1px solid #3b3950; background: #232135; color: inherit; font-family: ui-monospace, monospace" />
<button type="submit"
style="padding: 0.5rem 1rem; border-radius: 0.375rem; border: 0; background: #6d5ef0; color: white; font-weight: 500; cursor: pointer">
Connect
</button>
</form>
</div>
`;
document.documentElement.style.background = "#1b1a29";
document.getElementById("yaak-bridge-connect")?.addEventListener("submit", (e) => {
e.preventDefault();
const token = new FormData(e.target as HTMLFormElement).get("token");
if (typeof token !== "string" || token === "") return;
const url = new URL(window.location.href);
url.searchParams.set(TOKEN_QUERY_PARAM, token);
window.location.href = url.toString();
});
}
+7 -23
View File
@@ -1,30 +1,14 @@
import { createBridgePlatform } from "./bridge";
import { bridgeConfig, promptForToken, shouldUseBridge } from "./connect";
import { setPlatform } from "./registry";
import { createTauriPlatform } from "./tauri";
// This line is the swap point, and it has to run synchronously: several modules
// call commands while the module graph is still evaluating, so there is no
// later moment to install a host in.
// Desktop is the only host today, so it is installed unconditionally and
// synchronously — several modules call commands while the module graph is still
// evaluating, so there is no later moment to do this in.
//
// Both hosts are constructible without waiting for anything. The bridge host
// opens its connection in the background and queues calls made before it lands,
// which is why picking a host here does not mean blocking on one.
if (shouldUseBridge()) {
const config = bridgeConfig();
if (config == null) {
// No token yet: put the connect form on screen and install a host that
// never connects. Boot then stalls at its own top-level await rather than
// failing somewhere that doesn't explain itself — and it stalls in the one
// place already designed to wait, which keeps this file synchronous.
promptForToken();
setPlatform(createBridgePlatform(window.location.origin, null));
} else {
setPlatform(createBridgePlatform(config.url, config.token));
}
} else {
setPlatform(createTauriPlatform());
}
// This line is the swap point. A browser build selects its own host here, and
// because nothing else in the app imports a host directly, that is the whole
// change.
setPlatform(createTauriPlatform());
export * from "./capabilities";
export { platform, setPlatform } from "./registry";
+1 -21
View File
@@ -104,17 +104,6 @@ async function rpc<T>(cmd: string, payload?: RpcPayload): Promise<T> {
}
}
/**
* Where the backend put a response's body, or null if it has none.
*
* The app holds response ids and nothing else; a path exists for exactly as
* long as it takes this host to open the file, which is the one thing a desktop
* host can do that a tab cannot.
*/
function responseBodyPath(responseId: string): Promise<string | null> {
return rpc<string | null>("cmd_http_response_body_path", { responseId });
}
export function createTauriPlatform(): Platform {
const window = createWindow();
@@ -135,20 +124,11 @@ export function createTauriPlatform(): Platform {
},
files: {
readFile: (path) => readFile(path),
readDir: (path) => readDir(path),
url: (path) => convertFileSrc(path),
basename: (path) => basename(path),
resolveResource: (path) => resolveResource(path),
async readResponseBody(responseId) {
const path = await responseBodyPath(responseId);
return path == null ? null : readFile(path);
},
async responseBodyUrl(responseId) {
const path = await responseBodyPath(responseId);
return path == null ? null : convertFileSrc(path);
},
},
rpc,
+2 -19
View File
@@ -135,15 +135,11 @@ export interface PlatformDialog {
* File-ish operations, keyed by paths the backend handed us.
*
* A path here is an opaque handle, not something to parse or construct: the UI
* only ever passes one back to `url` or `readDir`. A host without a filesystem
* only ever passes one back to `readFile` or `url`. A host without a filesystem
* can mint handles of its own (a blob id, a URL) and stay compatible.
*
* Response bodies are addressed by response id instead, because they are the
* one thing every host stores somewhere different. The desktop reads the file
* the engine wrote; the bridge fetches it over HTTP. Neither is ever handed a
* location the page chose.
*/
export interface PlatformFiles {
readFile(path: string): Promise<Uint8Array<ArrayBuffer>>;
readDir(path: string): Promise<DirEntry[]>;
/** A URL the page can load a file from, for `<img>`, `<video>`, and friends. */
@@ -153,19 +149,6 @@ export interface PlatformFiles {
/** Resolve a path bundled with the app itself, rather than one from the backend. */
resolveResource(path: string): Promise<string>;
/** The bytes of a stored response body, or null if the response has none. */
readResponseBody(responseId: string): Promise<Uint8Array<ArrayBuffer> | null>;
/**
* A URL the media viewers can point an element at, or null if the response
* has no stored body.
*
* Asynchronous because the host may have to ask the backend where the body
* is: only the host is allowed to know that, and only the moment before it
* opens it.
*/
responseBodyUrl(responseId: string): Promise<string | null>;
}
export interface Platform {