mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-15 08:02:08 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e0f7d1818 | ||
|
|
dc793181bb | ||
|
|
7dfa7e07e3 |
Generated
+4
-109
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,142 +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_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)"),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
pub mod error;
|
||||
pub mod export;
|
||||
pub mod import;
|
||||
pub mod models_ops;
|
||||
pub mod plugin_events;
|
||||
pub mod render;
|
||||
pub mod send;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,290 +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";
|
||||
}
|
||||
|
||||
/**
|
||||
* A response body path is an opaque handle the backend minted, and the bridge
|
||||
* writes them as `<data dir>/responses/<response id>`. Taking the last segment
|
||||
* turns it back into the id the `/responses/{id}/body` route wants, which keeps
|
||||
* the server from ever being asked for a path chosen by the page.
|
||||
*/
|
||||
function responseIdFromBodyPath(path: string): string {
|
||||
const segments = path.split(/[/\\]/);
|
||||
return segments[segments.length - 1] ?? path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: {
|
||||
async readFile(path) {
|
||||
const res = await connection.fetch(`/responses/${responseIdFromBodyPath(path)}/body`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to read response body (${res.status})`);
|
||||
}
|
||||
return new Uint8Array(await res.arrayBuffer());
|
||||
},
|
||||
|
||||
readDir: async () => {
|
||||
throw unsupported("Browsing the filesystem");
|
||||
},
|
||||
|
||||
// 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.
|
||||
url: (path) => connection.url(`/responses/${responseIdFromBodyPath(path)}/body`),
|
||||
|
||||
basename: async (path) => path.split(/[/\\]/).pop() ?? path,
|
||||
resolveResource: async (path) => path,
|
||||
},
|
||||
|
||||
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",
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user