mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd431186b6 | ||
|
|
f7aea62c07 | ||
|
|
4d38a482f6 | ||
|
|
e82ffc53ad | ||
|
|
96ab0a8a60 | ||
|
|
974b9de673 | ||
|
|
e33aeee5ff | ||
|
|
39a7397ebb | ||
|
|
1a994f7532 | ||
|
|
4abc882e13 | ||
|
|
11aca4539d | ||
|
|
9bbd28ce4a | ||
|
|
686294939b | ||
|
|
6132171791 |
No files matched your search
@@ -5,3 +5,4 @@ crates/yaak-templates/pkg/* linguist-generated=true
|
||||
|
||||
# Ensure consistent line endings for test files that check exact content
|
||||
crates/yaak-http/tests/test.txt text eol=lf
|
||||
crates-proxy/yaak-proxy-lib/tests/fixtures/export.har text eol=lf
|
||||
Generated
+49
-9
@@ -708,13 +708,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "9.0.0"
|
||||
version = "9.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b"
|
||||
checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib 3.0.0",
|
||||
"alloc-stdlib 0.3.0",
|
||||
"brotli-decompressor 6.0.1",
|
||||
"brotli-decompressor 6.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -729,9 +729,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "6.0.1"
|
||||
version = "6.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7"
|
||||
checksum = "34c6783f42c21c952ad7112b354f0ba2f10760e9decea5b1b60523d6e270faaf"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib 3.0.0",
|
||||
"alloc-stdlib 0.3.0",
|
||||
@@ -1189,7 +1189,7 @@ version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
|
||||
dependencies = [
|
||||
"brotli 9.0.0",
|
||||
"brotli 9.1.1",
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
@@ -4977,15 +4977,27 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "objc2-security"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1f8e0ef3ab66b08c42644dcb34dba6ec0a574bbd8adbb8bdbdc7a2779731a44"
|
||||
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-service-management"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b213642d6959cc6023ceb1217aa595eaaf09b8094ce95127c103cab611fe65e8"
|
||||
dependencies = [
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-security",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-ui-kit"
|
||||
version = "0.3.1"
|
||||
@@ -11375,6 +11387,8 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-os",
|
||||
"yaak-mac-window",
|
||||
"yaak-proxy-lib",
|
||||
@@ -11571,7 +11585,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"async-trait",
|
||||
"brotli 8.0.1",
|
||||
"brotli 9.1.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"flate2",
|
||||
@@ -11751,10 +11765,24 @@ dependencies = [
|
||||
"tokio-tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-proxy-helper"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
"security-framework 3.5.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"ts-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-proxy-lib"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"flate2",
|
||||
@@ -11762,7 +11790,13 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"include_dir",
|
||||
"libc",
|
||||
"log 0.4.34",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-service-management",
|
||||
"plist",
|
||||
"r2d2",
|
||||
"r2d2_sqlite",
|
||||
"reqwest",
|
||||
@@ -11773,11 +11807,17 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"ts-rs",
|
||||
"url",
|
||||
"yaak-common",
|
||||
"yaak-database",
|
||||
"yaak-filter",
|
||||
"yaak-http",
|
||||
"yaak-lifecycle",
|
||||
"yaak-models",
|
||||
"yaak-proxy",
|
||||
"yaak-proxy-helper",
|
||||
"yaak-rpc",
|
||||
"yaak-templates",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -40,6 +40,7 @@ members = [
|
||||
"crates-tauri/yaak-fonts",
|
||||
"crates-tauri/yaak-license",
|
||||
"crates-tauri/yaak-mac-window",
|
||||
"crates-tauri/yaak-proxy-helper",
|
||||
"crates-tauri/yaak-system-appearance",
|
||||
"crates-tauri/yaak-tauri-utils",
|
||||
"crates-tauri/yaak-window",
|
||||
@@ -110,6 +111,7 @@ yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
|
||||
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
|
||||
yaak-license = { path = "crates-tauri/yaak-license" }
|
||||
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
|
||||
yaak-proxy-helper = { path = "crates-tauri/yaak-proxy-helper" }
|
||||
yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" }
|
||||
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
|
||||
yaak-window = { path = "crates-tauri/yaak-window" }
|
||||
|
||||
@@ -1,60 +1,19 @@
|
||||
import { Banner, Button, InlineCode } from "@yaakapp-internal/ui";
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import { Component, useEffect } from "react";
|
||||
import { ErrorBoundary as BaseErrorBoundary } from "@yaakapp-internal/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import RouteError from "./RouteError";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
name: string;
|
||||
children: ReactNode;
|
||||
export function ErrorBoundary({ name, children }: { name: string; children: ReactNode }) {
|
||||
return (
|
||||
<BaseErrorBoundary name={name} onShowError={showErrorDialog}>
|
||||
{children}
|
||||
</BaseErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.warn("Error caught by ErrorBoundary:", error, info);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Banner color="danger" className="flex items-center gap-2 overflow-auto">
|
||||
<div>
|
||||
Error rendering <InlineCode>{this.props.name}</InlineCode> component
|
||||
</div>
|
||||
<Button
|
||||
className="inline-flex"
|
||||
variant="border"
|
||||
color="danger"
|
||||
size="2xs"
|
||||
onClick={() => {
|
||||
showDialog({
|
||||
id: "error-boundary",
|
||||
render: () => <RouteError error={this.state.error} />,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Show
|
||||
</Button>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
export function showErrorDialog(error: unknown) {
|
||||
showDialog({ id: "error-boundary", render: () => <RouteError error={error} /> });
|
||||
}
|
||||
|
||||
export function ErrorBoundaryTestThrow() {
|
||||
|
||||
@@ -123,8 +123,6 @@ export default function Settings({ tab, hide }: Props) {
|
||||
<CountBadge count={settings.clientCertificates.length} />
|
||||
) : value === TAB_PLUGINS ? (
|
||||
<CountBadge count={plugins.filter((p) => p.source !== "bundled").length} />
|
||||
) : value === TAB_AGENTS && settings.mcpServerEnabled ? (
|
||||
<CountBadge count />
|
||||
) : value === TAB_PROXY && settings.proxy?.type === "enabled" ? (
|
||||
<CountBadge count />
|
||||
) : value === TAB_LICENSE && licenseCheck.check.data?.status === "personal_use" ? (
|
||||
|
||||
@@ -1,145 +1,72 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { Heading, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import type { AppMetaData } from "@yaakapp-internal/rpc-schema";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema";
|
||||
import { Banner, Heading, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { useEffect } from "react";
|
||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { usePluginInfos } from "../../hooks/usePluginInfo";
|
||||
import {
|
||||
AGENT_CLIENTS,
|
||||
type AgentClient,
|
||||
type AgentSetup,
|
||||
appSetup,
|
||||
cliSetup,
|
||||
skillInstallCommand,
|
||||
} from "../../lib/agentSetup";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
import { CopyIconButton } from "../CopyIconButton";
|
||||
import { Button } from "../core/Button";
|
||||
import { Editor } from "../core/Editor/LazyEditor";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { Link } from "../core/Link";
|
||||
import { SegmentedControl } from "../core/SegmentedControl";
|
||||
import {
|
||||
ModelSettingRowBoolean,
|
||||
SettingRow,
|
||||
SettingValue,
|
||||
SettingCommand,
|
||||
SettingSnippet,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
import { DeprecatedPluginBanner } from "./SettingsPlugins";
|
||||
|
||||
const MCP_URL = "http://127.0.0.1:64343/mcp";
|
||||
const CLI_INSTALL_COMMAND = "npm install -g @yaakapp/cli";
|
||||
const SKILL_INSTALL_COMMAND = "yaak agent install";
|
||||
const MCP_SERVER_PLUGIN = "@yaak/mcp-server";
|
||||
|
||||
export function SettingsAgents() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const { value: token } = useKeyValue<string | null>({
|
||||
namespace: "no_sync",
|
||||
key: "mcp_server_token",
|
||||
fallback: null,
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
const { value: storedClient, set: setClient } = useKeyValue<AgentClient>({
|
||||
key: "agents_setup_client",
|
||||
fallback: "claude_code",
|
||||
});
|
||||
const client = AGENT_CLIENTS.find((c) => c.value === storedClient)?.value ?? "claude_code";
|
||||
const clientLabel = AGENT_CLIENTS.find((c) => c.value === client)?.label ?? "your agent";
|
||||
const cliVersion = useQuery({
|
||||
queryKey: ["cli_version"],
|
||||
queryFn: async () => (await rpc<AppMetaData>("cmd_metadata")).cliVersion,
|
||||
initialData: appInfo.cliVersion,
|
||||
});
|
||||
const setupStatus = useQuery({
|
||||
queryKey: ["agent_setup_status", client],
|
||||
queryFn: () => rpc<AgentSetupStatus>("cmd_agent_setup_status", { agent: client }),
|
||||
});
|
||||
const mcpServerPlugin = usePluginInfos().find(({ info }) => info.name === MCP_SERVER_PLUGIN);
|
||||
|
||||
useEffect(() => {
|
||||
return platform.window.onFocusChanged((focused) => {
|
||||
if (!focused) return;
|
||||
void queryClient.invalidateQueries({ queryKey: ["cli_version"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["agent_setup_status"] });
|
||||
});
|
||||
}, [queryClient]);
|
||||
|
||||
const cliInstalled = cliVersion.data != null;
|
||||
const cli = cliSetup(client);
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Agents</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Connect AI coding agents like Claude Code and Cursor to Yaak, so they can read, edit, and
|
||||
send your requests.
|
||||
Connect AI coding agents like Claude Code and Cursor to Yaak.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsList className="space-y-8">
|
||||
<SettingsSection
|
||||
title="Yaak CLI"
|
||||
description="Lets agents use Yaak from the command line, even with the app closed."
|
||||
>
|
||||
<SettingRow
|
||||
title="Status"
|
||||
description={
|
||||
cliVersion.data == null
|
||||
? "The Yaak CLI is not installed."
|
||||
: `Yaak CLI ${cliVersion.data} is installed.`
|
||||
}
|
||||
controlClassName="gap-2"
|
||||
>
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon="refresh"
|
||||
title="Check again"
|
||||
spin={cliVersion.isFetching}
|
||||
onClick={() => cliVersion.refetch()}
|
||||
/>
|
||||
{cliVersion.data == null && (
|
||||
<Button
|
||||
variant="border"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={() => platform.openUrl("https://yaak.app/docs/cli")}
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
>
|
||||
Install CLI
|
||||
</Button>
|
||||
)}
|
||||
</SettingRow>
|
||||
{cliVersion.data == null ? (
|
||||
<SettingRow title="Install" description="Install it with npm.">
|
||||
<SettingValue value={CLI_INSTALL_COMMAND} />
|
||||
</SettingRow>
|
||||
) : (
|
||||
<SettingRow
|
||||
title="Agent skill"
|
||||
description={
|
||||
<>
|
||||
Teach Claude Code, Cursor, Codex, and opencode to drive the{" "}
|
||||
<InlineCode>yaak</InlineCode> command, wherever they're installed.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SettingValue value={SKILL_INSTALL_COMMAND} />
|
||||
</SettingRow>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="MCP server in the app"
|
||||
description="For agents that connect over HTTP, or to act on the workspaces open in Yaak."
|
||||
>
|
||||
<ModelSettingRowBoolean
|
||||
model={settings}
|
||||
modelKey="mcpServerEnabled"
|
||||
title="Enable MCP server"
|
||||
description={
|
||||
<>
|
||||
Serve Yaak's tools at <InlineCode>{MCP_URL}</InlineCode> while Yaak is open.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{settings.mcpServerEnabled && token != null && (
|
||||
<SettingRow
|
||||
title="Token"
|
||||
description="Agents send this as a bearer token."
|
||||
controlClassName="min-w-0 max-w-[min(24rem,40vw)] gap-2"
|
||||
>
|
||||
<SettingValue value={token} />
|
||||
</SettingRow>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Connect an agent"
|
||||
description="Pick your agent or editor to see how to add Yaak's MCP tools to it."
|
||||
>
|
||||
<div className="py-4 border-b border-border-subtle">
|
||||
<SettingsList>
|
||||
<SettingsSection title={null}>
|
||||
<div className="pb-4 border-b border-border-subtle">
|
||||
<SegmentedControl
|
||||
name="agents_setup_client"
|
||||
label="Agent"
|
||||
@@ -149,59 +76,65 @@ export function SettingsAgents() {
|
||||
options={AGENT_CLIENTS}
|
||||
/>
|
||||
</div>
|
||||
<SetupRow
|
||||
title="Through the Yaak CLI"
|
||||
description={
|
||||
cliVersion.data == null
|
||||
? "Recommended. Install the Yaak CLI above first."
|
||||
: `Recommended. ${cliSetup(client).where}`
|
||||
}
|
||||
setup={cliSetup(client)}
|
||||
/>
|
||||
{settings.mcpServerEnabled && token != null ? (
|
||||
<SetupRow
|
||||
title="Through the app"
|
||||
description={appSetup(client, MCP_URL, token).where}
|
||||
setup={appSetup(client, MCP_URL, token)}
|
||||
/>
|
||||
{cliInstalled ? (
|
||||
<div className="flex items-center gap-2 py-3 text-sm text-text-subtle border-b border-border-subtle">
|
||||
<Icon icon="check" size="sm" color="success" />
|
||||
<span>
|
||||
Yaak CLI <span className="text-text">{cliVersion.data}</span> is installed.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<SettingRow
|
||||
title="Through the app"
|
||||
description="Turn on the MCP server above to connect this way."
|
||||
>
|
||||
<span />
|
||||
</SettingRow>
|
||||
<div className="py-4 border-b border-border-subtle">
|
||||
<Banner color="info">
|
||||
<div className="flex items-center gap-2 text text-text">
|
||||
<Icon icon="square_terminal" color="info" />
|
||||
Install the Yaak CLI first
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-text-subtle">
|
||||
Agents launch it to reach Yaak, even with the app closed.
|
||||
</p>
|
||||
<SettingSnippet className="mt-3" language="shell" snippet={CLI_INSTALL_COMMAND} />
|
||||
<div className="mt-3 mb-1 flex items-center gap-4 text-sm">
|
||||
<Link href="https://yaak.app/docs/cli">Docs</Link>
|
||||
<span className="text-text-subtlest">
|
||||
Checks again when you come back to Yaak.
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
</div>
|
||||
)}
|
||||
{mcpServerPlugin != null && (
|
||||
<div className="py-4 border-b border-border-subtle">
|
||||
<DeprecatedPluginBanner
|
||||
plugin={mcpServerPlugin.plugin}
|
||||
name={mcpServerPlugin.info.displayName}
|
||||
>
|
||||
The two steps below replace it. Uninstall it once your agent is connected.
|
||||
</DeprecatedPluginBanner>
|
||||
</div>
|
||||
)}
|
||||
<SettingCommand
|
||||
title="Add the MCP server"
|
||||
description={cli.where}
|
||||
snippet={cli.snippet}
|
||||
language={cli.language}
|
||||
disabled={!cliInstalled}
|
||||
done={setupStatus.data?.mcpServer ? "Added" : null}
|
||||
/>
|
||||
<SettingCommand
|
||||
title="Install the skill"
|
||||
description={
|
||||
<>
|
||||
Lets {clientLabel} run the <InlineCode>yaak</InlineCode> command directly.
|
||||
</>
|
||||
}
|
||||
snippet={skillInstallCommand(client)}
|
||||
language="shell"
|
||||
disabled={!cliInstalled}
|
||||
done={setupStatus.data?.skill ? "Installed" : null}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupRow({
|
||||
title,
|
||||
description,
|
||||
setup,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
setup: AgentSetup;
|
||||
}) {
|
||||
return (
|
||||
<div className="py-4 border-b border-border-subtle">
|
||||
<div className="text text-text">{title}</div>
|
||||
<div className="mt-1 text-sm text-text-subtle">{description}</div>
|
||||
<Editor
|
||||
readOnly
|
||||
hideGutter
|
||||
wrapLines
|
||||
heightMode="auto"
|
||||
stateKey={null}
|
||||
language={setup.language}
|
||||
defaultValue={setup.snippet}
|
||||
className="mt-3 border border-border-subtle rounded-md px-2"
|
||||
actions={<CopyIconButton size="sm" variant="border" text={setup.snippet} title="Copy setup" />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,9 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { Plugin } from "@yaakapp-internal/models";
|
||||
import { patchModel, pluginsAtom } from "@yaakapp-internal/models";
|
||||
import type { PluginVersion } from "@yaakapp-internal/plugins";
|
||||
import { checkPluginUpdates, installPlugin, searchPlugins } from "@yaakapp-internal/plugins";
|
||||
import {
|
||||
checkPluginUpdates,
|
||||
installPlugin,
|
||||
searchPlugins,
|
||||
uninstallPlugin,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
import {
|
||||
Banner,
|
||||
HStack,
|
||||
Icon,
|
||||
InlineCode,
|
||||
@@ -24,12 +20,11 @@ import {
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useInstallPlugin } from "../../hooks/useInstallPlugin";
|
||||
import { usePluginInfo } from "../../hooks/usePluginInfo";
|
||||
import { usePluginInfo, usePluginInfos } from "../../hooks/usePluginInfo";
|
||||
import { usePluginsKey, useRefreshPlugins } from "../../hooks/usePlugins";
|
||||
import { showConfirmDelete } from "../../lib/confirm";
|
||||
import { minPromiseMillis } from "../../lib/minPromiseMillis";
|
||||
import { usePromptUninstallPlugin } from "../../hooks/useUninstallPlugin";
|
||||
import { Button } from "../core/Button";
|
||||
import { Checkbox } from "../core/Checkbox";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
@@ -205,7 +200,7 @@ function PluginTableRow({
|
||||
mutationKey: ["install_plugin", name],
|
||||
mutationFn: (name: string) => installPlugin(name, null),
|
||||
});
|
||||
const uninstall = usePromptUninstall(plugin?.id ?? null, displayName);
|
||||
const uninstall = usePromptUninstallPlugin(plugin?.id ?? null, displayName);
|
||||
const refreshPlugins = useRefreshPlugins();
|
||||
|
||||
return (
|
||||
@@ -296,7 +291,10 @@ function PluginSearch() {
|
||||
const debouncedQuery = useDebouncedValue(query);
|
||||
const results = useQuery({
|
||||
queryKey: ["plugins", debouncedQuery],
|
||||
queryFn: () => searchPlugins(query),
|
||||
queryFn: async () => {
|
||||
const { plugins } = await searchPlugins(query);
|
||||
return { plugins: plugins.filter((p) => p.deprecated == null) };
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -349,22 +347,77 @@ function InstalledPlugins({ plugins, className }: { plugins: Plugin[]; className
|
||||
</EmptyStateText>
|
||||
</div>
|
||||
) : (
|
||||
<Table scrollable className={className}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="w-0" />
|
||||
<TableHeaderCell>Display Name</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{plugins.map((p) => (
|
||||
<PluginTableRowForInstalledPlugin key={p.id} plugin={p} />
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
<div className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)]")}>
|
||||
<DeprecatedPluginBanners />
|
||||
<Table scrollable>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="w-0" />
|
||||
<TableHeaderCell>Display Name</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{plugins.map((p) => (
|
||||
<PluginTableRowForInstalledPlugin key={p.id} plugin={p} />
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeprecatedPluginBanners() {
|
||||
const deprecated = usePluginInfos().filter(
|
||||
({ plugin, info }) => plugin.source !== "bundled" && info.deprecated != null,
|
||||
);
|
||||
return (
|
||||
<div className={classNames("flex flex-col gap-2", deprecated.length > 0 && "pb-3")}>
|
||||
{deprecated.map(({ plugin, info }) => (
|
||||
<DeprecatedPluginBanner key={plugin.id} plugin={plugin} name={info.displayName}>
|
||||
{withInlineCode(info.deprecated ?? "")}
|
||||
</DeprecatedPluginBanner>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Backtick spans in a package.json string, set as code. */
|
||||
function withInlineCode(text: string): ReactNode {
|
||||
return text
|
||||
.split("`")
|
||||
.map((part, i) => (i % 2 === 1 ? <InlineCode key={i}>{part}</InlineCode> : part));
|
||||
}
|
||||
|
||||
export function DeprecatedPluginBanner({
|
||||
children,
|
||||
name,
|
||||
plugin,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
name: string;
|
||||
plugin: Plugin;
|
||||
}) {
|
||||
const uninstall = usePromptUninstallPlugin(plugin.id, name);
|
||||
return (
|
||||
<Banner color="info" className="flex items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text text-text">The {name} plugin is deprecated.</div>
|
||||
<p className="mt-1 text-sm text-text-subtle">{children}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
title="Uninstall plugin"
|
||||
className="shrink-0"
|
||||
isLoading={uninstall?.isPending}
|
||||
onClick={() => uninstall?.mutate()}
|
||||
>
|
||||
Uninstall
|
||||
</Button>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -393,31 +446,6 @@ function BundledPlugins({ plugins }: { plugins: Plugin[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function usePromptUninstall(pluginId: string | null, name: string) {
|
||||
const mut = useMutation({
|
||||
mutationKey: ["uninstall_plugin", pluginId],
|
||||
mutationFn: async () => {
|
||||
if (pluginId == null) return;
|
||||
|
||||
const confirmed = await showConfirmDelete({
|
||||
id: `uninstall-plugin-${pluginId}`,
|
||||
title: "Uninstall Plugin",
|
||||
confirmText: "Uninstall",
|
||||
description: (
|
||||
<>
|
||||
Permanently uninstall <InlineCode>{name}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (confirmed) {
|
||||
await minPromiseMillis(uninstallPlugin(pluginId), 700);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return pluginId == null ? null : mut;
|
||||
}
|
||||
|
||||
function usePluginUpdates() {
|
||||
return useQuery({
|
||||
queryKey: ["plugin_updates", usePluginsKey()],
|
||||
|
||||
@@ -1,43 +1,6 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { hideToast, toastsAtom } from "../lib/toast";
|
||||
import { Toast, type ToastProps } from "./core/Toast";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { Portal } from "@yaakapp-internal/ui";
|
||||
import { Toasts as BaseToasts } from "@yaakapp-internal/ui";
|
||||
import { showErrorDialog } from "./ErrorBoundary";
|
||||
|
||||
export type ToastInstance = {
|
||||
id: string;
|
||||
uniqueKey: string;
|
||||
message: ReactNode;
|
||||
timeout: 3000 | 5000 | 8000 | (number & {}) | null;
|
||||
onClose?: ToastProps["onClose"];
|
||||
} & Omit<ToastProps, "onClose" | "open" | "children" | "timeout">;
|
||||
export type { ToastInstance } from "@yaakapp-internal/ui";
|
||||
|
||||
export const Toasts = () => {
|
||||
const toasts = useAtomValue(toastsAtom);
|
||||
return (
|
||||
<Portal name="toasts">
|
||||
<div className="absolute right-0 bottom-0 z-50">
|
||||
<AnimatePresence>
|
||||
{toasts.map((toast: ToastInstance) => {
|
||||
const { message, uniqueKey, ...props } = toast;
|
||||
return (
|
||||
<ErrorBoundary key={uniqueKey} name={`Toast ${uniqueKey}`}>
|
||||
<Toast
|
||||
open
|
||||
{...props}
|
||||
// We call onClose inside actions.hide instead of passing to toast so that
|
||||
// it gets called from external close calls as well
|
||||
onClose={() => hideToast(toast)}
|
||||
>
|
||||
{message}
|
||||
</Toast>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
export const Toasts = () => <BaseToasts onShowError={showErrorDialog} />;
|
||||
@@ -1,72 +1,2 @@
|
||||
import { HStack, Icon, IconTooltip } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface CheckboxProps {
|
||||
checked: boolean | "indeterminate";
|
||||
title: ReactNode;
|
||||
onChange: (checked: boolean) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
inputWrapperClassName?: string;
|
||||
hideLabel?: boolean;
|
||||
fullWidth?: boolean;
|
||||
help?: ReactNode;
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
checked,
|
||||
onChange,
|
||||
className,
|
||||
inputWrapperClassName,
|
||||
disabled,
|
||||
title,
|
||||
hideLabel,
|
||||
fullWidth,
|
||||
help,
|
||||
size = "sm",
|
||||
}: CheckboxProps) {
|
||||
return (
|
||||
<HStack
|
||||
as="label"
|
||||
alignItems="center"
|
||||
space={2}
|
||||
className={classNames(className, "text-text mr-auto")}
|
||||
>
|
||||
<div className={classNames(inputWrapperClassName, "x-theme-input", "relative flex mr-0.5")}>
|
||||
<input
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
"appearance-none shrink-0 border border-border",
|
||||
size === "sm" && "w-4 h-4",
|
||||
size === "md" && "w-5 h-5",
|
||||
"rounded-sm outline-hidden ring-0",
|
||||
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
|
||||
disabled && "border-dotted",
|
||||
)}
|
||||
type="checkbox"
|
||||
disabled={disabled}
|
||||
onChange={() => {
|
||||
onChange(checked === "indeterminate" ? true : !checked);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Icon
|
||||
size={size}
|
||||
className={classNames(disabled && "opacity-disabled")}
|
||||
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!hideLabel && (
|
||||
<div
|
||||
className={classNames("text-sm", fullWidth && "w-full", disabled && "opacity-disabled")}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{help && <IconTooltip content={help} />}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
export type { CheckboxProps } from "@yaakapp-internal/ui";
|
||||
export { Checkbox } from "@yaakapp-internal/ui";
|
||||
@@ -1,200 +1,2 @@
|
||||
import type { DialogSize } from "@yaakapp-internal/plugins";
|
||||
import { Heading, HStack, Overlay } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import * as m from "motion/react-m";
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, useContext, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button, type ButtonProps } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
export interface DialogProps {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
|
||||
disableClose?: boolean;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
size?: DialogSize;
|
||||
/** Hide the built-in close button without changing backdrop or Escape behavior. */
|
||||
hideX?: boolean;
|
||||
noPadding?: boolean;
|
||||
noScroll?: boolean;
|
||||
vAlign?: "top" | "center";
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
children,
|
||||
className,
|
||||
size = "full",
|
||||
open,
|
||||
onClose,
|
||||
disableClose,
|
||||
title,
|
||||
description,
|
||||
hideX,
|
||||
noPadding,
|
||||
noScroll,
|
||||
vAlign = "center",
|
||||
}: DialogProps) {
|
||||
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
const descriptionId = useMemo(
|
||||
() => (description ? Math.random().toString(36).slice(2) : undefined),
|
||||
[description],
|
||||
);
|
||||
|
||||
return (
|
||||
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
|
||||
<div
|
||||
role="dialog"
|
||||
className={classNames(
|
||||
"py-4 x-theme-dialog absolute inset-0 pointer-events-none",
|
||||
"h-full flex flex-col items-center justify-center",
|
||||
vAlign === "top" && "justify-start",
|
||||
vAlign === "center" && "justify-center",
|
||||
)}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId}
|
||||
tabIndex={-1}
|
||||
onKeyDown={(e) => {
|
||||
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
|
||||
// dialogs and can be intercepted by children if needed.
|
||||
if (e.key === "Escape") {
|
||||
if (!disableClose) onClose?.();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<m.div
|
||||
initial={{ top: 5, scale: 0.97 }}
|
||||
animate={{ top: 0, scale: 1 }}
|
||||
className={classNames(
|
||||
className,
|
||||
"grid grid-rows-[auto_auto_minmax(0,1fr)_auto]",
|
||||
"grid-cols-1", // must be here for inline code blocks to correctly break words
|
||||
"relative bg-surface pointer-events-auto",
|
||||
"rounded-lg",
|
||||
"border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]",
|
||||
"min-h-40",
|
||||
"max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]",
|
||||
size === "sm" && "w-120",
|
||||
size === "md" && "w-200",
|
||||
size === "lg" && "w-280",
|
||||
size === "full" && "w-screen h-screen",
|
||||
size === "dynamic" && "min-w-80 max-w-[100vw]",
|
||||
)}
|
||||
>
|
||||
{title ? (
|
||||
<Heading className="px-6 mt-4 mb-2" level={1} id={titleId}>
|
||||
{title}
|
||||
</Heading>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
||||
{description ? (
|
||||
<div className="min-h-0 px-6 text-text-subtle mb-3" id={descriptionId}>
|
||||
{description}
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"h-full w-full grid grid-cols-[minmax(0,1fr)] grid-rows-1",
|
||||
!noPadding && "px-6 py-2",
|
||||
!noScroll && "overflow-y-auto overflow-x-hidden",
|
||||
)}
|
||||
>
|
||||
<DialogFooterSlot.Provider value={footerEl}>{children}</DialogFooterSlot.Provider>
|
||||
</div>
|
||||
|
||||
<div ref={setFooterEl} className="contents" />
|
||||
|
||||
{/*Put close at the end so that it's the last thing to be tabbed to*/}
|
||||
{!disableClose && !hideX && (
|
||||
<div className="ml-auto absolute right-1 top-1">
|
||||
<IconButton
|
||||
className="opacity-70 hover:opacity-100"
|
||||
onClick={onClose}
|
||||
title="Close dialog (Esc)"
|
||||
aria-label="Close"
|
||||
size="sm"
|
||||
icon="x"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</m.div>
|
||||
</div>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
const DialogFooterSlot = createContext<HTMLDivElement | null>(null);
|
||||
|
||||
export interface DialogFooterAction {
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
/** Submit the form with this id, which can be anywhere in the dialog body */
|
||||
form?: string;
|
||||
color?: ButtonProps["color"];
|
||||
variant?: ButtonProps["variant"];
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
autoFocus?: boolean;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The action row at the bottom of a dialog. Render it anywhere in the dialog's content and it is
|
||||
* placed below the scrolling body, outside its padding. `inline` drops the divider for small
|
||||
* dialogs whose body never scrolls.
|
||||
*/
|
||||
export function DialogFooter({
|
||||
actions,
|
||||
leftSlot,
|
||||
inline,
|
||||
}: {
|
||||
actions: DialogFooterAction[];
|
||||
leftSlot?: ReactNode;
|
||||
inline?: boolean;
|
||||
}) {
|
||||
const slot = useContext(DialogFooterSlot);
|
||||
const footer = (
|
||||
<footer
|
||||
className={classNames(
|
||||
"px-6 flex items-center gap-3",
|
||||
inline ? "pt-1 pb-4" : "py-3 border-t border-border-subtle",
|
||||
)}
|
||||
>
|
||||
{leftSlot != null && <div className="mr-auto min-w-0">{leftSlot}</div>}
|
||||
<HStack space={2} justifyContent="end" className="ml-auto shrink-0">
|
||||
{actions.map((action, i) => (
|
||||
<Button
|
||||
// Labels change with state ("Cloning...", "Apply 3 Changes"), so they can't be keys
|
||||
key={i}
|
||||
type={action.form != null ? "submit" : "button"}
|
||||
form={action.form}
|
||||
color={action.color ?? "secondary"}
|
||||
variant={action.variant ?? (action.color == null ? "border" : "solid")}
|
||||
disabled={action.disabled}
|
||||
isLoading={action.isLoading}
|
||||
autoFocus={action.autoFocus}
|
||||
leftSlot={action.leftSlot}
|
||||
rightSlot={action.rightSlot}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</HStack>
|
||||
</footer>
|
||||
);
|
||||
return slot == null ? footer : createPortal(footer, slot);
|
||||
}
|
||||
export type { DialogFooterAction, DialogProps } from "@yaakapp-internal/ui";
|
||||
export { Dialog, DialogFooter } from "@yaakapp-internal/ui";
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,45 +1 @@
|
||||
import { HStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { HotkeyAction } from "../../hooks/useHotKey";
|
||||
import { useFormattedHotkey } from "../../hooks/useHotKey";
|
||||
|
||||
interface Props {
|
||||
action: HotkeyAction | null;
|
||||
className?: string;
|
||||
variant?: "text" | "with-bg";
|
||||
}
|
||||
|
||||
export function Hotkey({ action, className, variant }: Props) {
|
||||
const labelParts = useFormattedHotkey(action);
|
||||
if (labelParts === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HotkeyRaw labelParts={labelParts} className={className} variant={variant} />;
|
||||
}
|
||||
|
||||
interface HotkeyRawProps {
|
||||
labelParts: string[];
|
||||
className?: string;
|
||||
variant?: "text" | "with-bg";
|
||||
}
|
||||
|
||||
export function HotkeyRaw({ labelParts, className, variant }: HotkeyRawProps) {
|
||||
return (
|
||||
<HStack
|
||||
className={classNames(
|
||||
className,
|
||||
variant === "with-bg" &&
|
||||
"rounded-sm bg-surface-highlight px-1 border border-border text-text-subtle",
|
||||
variant === "text" && "text-text-subtlest",
|
||||
)}
|
||||
>
|
||||
{labelParts.map((char, index) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<div key={index} className="min-w-[1em] text-center">
|
||||
{char}
|
||||
</div>
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
export { Hotkey, HotkeyRaw } from "@yaakapp-internal/ui";
|
||||
@@ -1,242 +1,2 @@
|
||||
import { HStack, useStateWithDeps, Label } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useRandomKey } from "../../hooks/useRandomKey";
|
||||
import { IconButton } from "./IconButton";
|
||||
import type { InputProps } from "./Input";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
|
||||
export type PlainInputProps = Omit<
|
||||
InputProps,
|
||||
| "wrapLines"
|
||||
| "onKeyDown"
|
||||
| "type"
|
||||
| "stateKey"
|
||||
| "autocompleteVariables"
|
||||
| "autocompleteFunctions"
|
||||
| "autocomplete"
|
||||
| "extraExtensions"
|
||||
| "forcedEnvironmentId"
|
||||
> &
|
||||
Pick<InputHTMLAttributes<HTMLInputElement>, "inputMode" | "onKeyDownCapture" | "step"> & {
|
||||
onFocusRaw?: InputHTMLAttributes<HTMLInputElement>["onFocus"];
|
||||
type?: "text" | "password" | "number";
|
||||
hideObscureToggle?: boolean;
|
||||
labelRightSlot?: ReactNode;
|
||||
};
|
||||
|
||||
export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(function PlainInput(
|
||||
{
|
||||
autoFocus,
|
||||
autoSelect,
|
||||
className,
|
||||
containerClassName,
|
||||
defaultValue,
|
||||
disabled,
|
||||
forceUpdateKey: forceUpdateKeyFromAbove,
|
||||
help,
|
||||
hideLabel,
|
||||
hideObscureToggle,
|
||||
label,
|
||||
labelClassName,
|
||||
labelPosition = "top",
|
||||
labelRightSlot,
|
||||
inputMode,
|
||||
leftSlot,
|
||||
name,
|
||||
onBlur,
|
||||
onChange,
|
||||
onFocus,
|
||||
onFocusRaw,
|
||||
onKeyDownCapture,
|
||||
onPaste,
|
||||
placeholder,
|
||||
required,
|
||||
rightSlot,
|
||||
size = "md",
|
||||
step,
|
||||
tint,
|
||||
type = "text",
|
||||
validate,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
// Track a local key for updates. If the default value is changed when the input is not in focus,
|
||||
// regenerate this to force the field to update.
|
||||
const [focusedUpdateKey, regenerateFocusedUpdateKey] = useRandomKey();
|
||||
const forceUpdateKey = `${forceUpdateKeyFromAbove}::${focusedUpdateKey}`;
|
||||
|
||||
const [obscured, setObscured] = useStateWithDeps(type === "password", [type]);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useImperativeHandle<{ focus: () => void } | null, { focus: () => void } | null>(
|
||||
ref,
|
||||
() => inputRef.current,
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(e: FocusEvent<HTMLInputElement>) => {
|
||||
onFocusRaw?.(e);
|
||||
setFocused(true);
|
||||
if (autoSelect) {
|
||||
inputRef.current?.select();
|
||||
textareaRef.current?.select();
|
||||
}
|
||||
onFocus?.();
|
||||
},
|
||||
[autoSelect, onFocus, onFocusRaw],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setFocused(false);
|
||||
onBlur?.();
|
||||
}, [onBlur]);
|
||||
|
||||
// Force input to update when receiving change and not in focus
|
||||
useLayoutEffect(() => {
|
||||
const isFocused = document.activeElement === inputRef.current;
|
||||
if (defaultValue != null && !isFocused) {
|
||||
regenerateFocusedUpdateKey();
|
||||
}
|
||||
}, [regenerateFocusedUpdateKey, defaultValue]);
|
||||
|
||||
const id = useRef(`input-${generateId()}`);
|
||||
const commonClassName = classNames(
|
||||
className,
|
||||
"bg-transparent! min-w-0 w-full focus:outline-hidden placeholder:text-placeholder",
|
||||
"px-2 text-xs font-mono cursor-text",
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
onChange?.(value);
|
||||
setHasChanged(true);
|
||||
const isValid = (value: string) => {
|
||||
if (required && !validateRequire(value)) return false;
|
||||
if (typeof validate === "boolean") return validate;
|
||||
if (typeof validate === "function" && !validate(value)) return false;
|
||||
return true;
|
||||
};
|
||||
inputRef.current?.setCustomValidity(isValid(value) ? "" : "Invalid value");
|
||||
},
|
||||
[onChange, required, setHasChanged, validate],
|
||||
);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={classNames(
|
||||
"w-full",
|
||||
"pointer-events-auto", // Just in case we're placing in disabled parent
|
||||
labelPosition === "left" && "flex items-center gap-2",
|
||||
labelPosition === "top" && "flex-row gap-0.5",
|
||||
)}
|
||||
>
|
||||
<Label
|
||||
htmlFor={id.current}
|
||||
className={labelClassName}
|
||||
visuallyHidden={hideLabel}
|
||||
required={required}
|
||||
help={help}
|
||||
rightSlot={labelRightSlot}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
<HStack
|
||||
alignItems="stretch"
|
||||
className={classNames(
|
||||
containerClassName,
|
||||
"x-theme-input",
|
||||
"relative w-full rounded-md text",
|
||||
"border",
|
||||
"overflow-hidden",
|
||||
focused && !disabled ? "border-border-focus" : "border-border-subtle",
|
||||
disabled && "border-dotted",
|
||||
hasChanged && "has-invalid:border-danger", // For built-in HTML validation
|
||||
size === "md" && "min-h-md",
|
||||
size === "sm" && "min-h-sm",
|
||||
size === "xs" && "min-h-xs",
|
||||
size === "2xs" && "min-h-2xs",
|
||||
)}
|
||||
>
|
||||
{tint != null && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
"absolute inset-0 opacity-5 pointer-events-none",
|
||||
tint === "info" && "bg-info",
|
||||
tint === "warning" && "bg-warning",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{leftSlot}
|
||||
<HStack
|
||||
className={classNames(
|
||||
"w-full min-w-0",
|
||||
leftSlot ? "pl-0.5 -ml-2" : null,
|
||||
rightSlot ? "pr-0.5 -mr-2" : null,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
id={id.current}
|
||||
ref={inputRef}
|
||||
key={forceUpdateKey}
|
||||
type={type === "password" && !obscured ? "text" : type}
|
||||
name={name}
|
||||
// oxlint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
defaultValue={defaultValue ?? undefined}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
writingsuggestions="false"
|
||||
inputMode={inputMode}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
|
||||
className={classNames(commonClassName, "h-full disabled:opacity-disabled")}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
required={required}
|
||||
step={step}
|
||||
placeholder={placeholder}
|
||||
onKeyDownCapture={onKeyDownCapture}
|
||||
/>
|
||||
</HStack>
|
||||
{type === "password" && !hideObscureToggle && (
|
||||
<IconButton
|
||||
title={
|
||||
obscured
|
||||
? `Show ${typeof label === "string" ? label : "field"}`
|
||||
: `Obscure ${typeof label === "string" ? label : "field"}`
|
||||
}
|
||||
size="xs"
|
||||
className="mr-0.5 group/obscure h-auto! my-0.5"
|
||||
iconClassName="group-hover/obscure:text"
|
||||
iconSize="sm"
|
||||
icon={obscured ? "eye" : "eye_closed"}
|
||||
onClick={() => setObscured((o) => !o)}
|
||||
/>
|
||||
)}
|
||||
{rightSlot}
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function validateRequire(v: string) {
|
||||
return v.length > 0;
|
||||
}
|
||||
export type { PlainInputProps } from "@yaakapp-internal/ui";
|
||||
export { PlainInput } from "@yaakapp-internal/ui";
|
||||
@@ -1,77 +1,2 @@
|
||||
import type { Color } from "@yaakapp-internal/plugins";
|
||||
import type { IconProps } from "@yaakapp-internal/ui";
|
||||
import { IconButton } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* A single control attached to a labelled separator, rendered between the label
|
||||
* and the rule.
|
||||
*
|
||||
* Declared rather than passed as a node so the separator keeps ownership of the
|
||||
* things that are easy to get wrong by hand: matching the label's colour, and
|
||||
* staying out of the rule's way when the label is long.
|
||||
*/
|
||||
export interface SeparatorAction {
|
||||
icon: IconProps["icon"];
|
||||
/** Tooltip and accessible name. Required — the control is icon-only. */
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
orientation?: "horizontal" | "vertical";
|
||||
dashed?: boolean;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
color?: Color;
|
||||
action?: SeparatorAction;
|
||||
}
|
||||
|
||||
export function Separator({
|
||||
color,
|
||||
className,
|
||||
dashed,
|
||||
orientation = "horizontal",
|
||||
children,
|
||||
action,
|
||||
}: Props) {
|
||||
return (
|
||||
<div role="presentation" className={classNames(className, "flex items-center w-full")}>
|
||||
{children && (
|
||||
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
|
||||
)}
|
||||
{action && (
|
||||
<IconButton
|
||||
size="2xs"
|
||||
iconSize="xs"
|
||||
className="shrink-0 mr-2 -ml-1"
|
||||
// Forced, because the button itself sets `text-text` at full strength.
|
||||
iconClassName="text-text-subtlest!"
|
||||
icon={action.icon}
|
||||
title={action.title}
|
||||
onClick={action.onClick}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"opacity-60",
|
||||
// Keep a stub of the line visible no matter how long the label is —
|
||||
// `w-full` alone gets squeezed to nothing by a wide label.
|
||||
orientation === "horizontal" && "min-w-8",
|
||||
color == null && "border-border",
|
||||
color === "primary" && "border-primary",
|
||||
color === "secondary" && "border-secondary",
|
||||
color === "success" && "border-success",
|
||||
color === "notice" && "border-notice",
|
||||
color === "warning" && "border-warning",
|
||||
color === "danger" && "border-danger",
|
||||
color === "info" && "border-info",
|
||||
dashed && "border-dashed",
|
||||
orientation === "horizontal" && "w-full h-0 border-t",
|
||||
orientation === "vertical" && "h-full w-0 border-l",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export type { SeparatorAction } from "@yaakapp-internal/ui";
|
||||
export { Separator } from "@yaakapp-internal/ui";
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AnyModel } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import { CopyIconButton } from "../CopyIconButton";
|
||||
@@ -139,6 +140,90 @@ export function SettingValue({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A command or config to copy, set as a block: its title, an optional line
|
||||
* saying which file it goes in, then the snippet at full width with the copy
|
||||
* button on it. One-line shell commands and multi-line JSON get the same block.
|
||||
*/
|
||||
export function SettingCommand({
|
||||
className,
|
||||
description,
|
||||
disabled,
|
||||
done,
|
||||
language,
|
||||
snippet,
|
||||
title,
|
||||
}: {
|
||||
description?: ReactNode;
|
||||
disabled?: boolean;
|
||||
/** A short word shown with a check beside the title once the step is detected as done */
|
||||
done?: ReactNode;
|
||||
language: SettingSnippetLanguage;
|
||||
snippet: string;
|
||||
title: ReactNode;
|
||||
} & Pick<SettingRowBaseProps, "className">) {
|
||||
return (
|
||||
<div
|
||||
aria-disabled={disabled || undefined}
|
||||
className={classNames(
|
||||
className,
|
||||
"border-b border-border-subtle py-4",
|
||||
disabled && "opacity-disabled",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 text text-text">
|
||||
<span>{title}</span>
|
||||
{done != null && done !== false && (
|
||||
<span className="inline-flex items-center gap-1 text-sm text-success">
|
||||
<Icon icon="check" size="sm" />
|
||||
{done}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description != null && (
|
||||
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
|
||||
)}
|
||||
<SettingSnippet className="mt-3" language={language} snippet={snippet} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type SettingSnippetLanguage = "shell" | "json" | "text";
|
||||
|
||||
/** The snippet half of a `SettingCommand`, for places with their own title, like a banner. */
|
||||
export function SettingSnippet({
|
||||
className,
|
||||
language,
|
||||
snippet,
|
||||
}: {
|
||||
className?: string;
|
||||
language: SettingSnippetLanguage;
|
||||
snippet: string;
|
||||
}) {
|
||||
const prompt = language === "shell";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
"flex items-start gap-2 rounded-md border border-border-subtle bg-surface-highlight/40 py-1.5 pr-1.5 pl-3",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-sm min-w-0 flex-1 items-center font-mono text-editor text-text">
|
||||
<span className="cursor-text select-text whitespace-pre-wrap [overflow-wrap:anywhere]">
|
||||
{prompt && (
|
||||
<span aria-hidden className="mr-2 select-none text-text-subtlest">
|
||||
$
|
||||
</span>
|
||||
)}
|
||||
{snippet}
|
||||
</span>
|
||||
</div>
|
||||
<CopyIconButton size="sm" variant="border" title="Copy" text={snippet} className="shrink-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SettingValueAction = {
|
||||
icon: IconButtonProps["icon"];
|
||||
onClick: () => void;
|
||||
|
||||
@@ -1,148 +1,2 @@
|
||||
import type { ShowToastRequest } from "@yaakapp-internal/plugins";
|
||||
import { Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import * as m from "motion/react-m";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useKey } from "react-use";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
export interface ToastProps {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
timeout: number | null;
|
||||
action?: (args: { hide: () => void }) => ReactNode;
|
||||
icon?: ShowToastRequest["icon"] | null;
|
||||
color?: ShowToastRequest["color"];
|
||||
// Grow with the content (up to the viewport) instead of scrolling internally
|
||||
// past the default max height
|
||||
dynamicHeight?: boolean;
|
||||
// Hide the close button, for toasts that render their own dismiss action.
|
||||
// Escape still closes the toast
|
||||
hideDismiss?: boolean;
|
||||
}
|
||||
|
||||
const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon"] | null> = {
|
||||
custom: null,
|
||||
danger: "alert_triangle",
|
||||
info: "info",
|
||||
notice: "alert_triangle",
|
||||
primary: "info",
|
||||
secondary: "info",
|
||||
success: "check_circle",
|
||||
warning: "alert_triangle",
|
||||
};
|
||||
|
||||
export function Toast({
|
||||
children,
|
||||
open,
|
||||
onClose,
|
||||
timeout,
|
||||
action,
|
||||
icon,
|
||||
color,
|
||||
dynamicHeight,
|
||||
hideDismiss,
|
||||
}: ToastProps) {
|
||||
const onCloseRef = useRef(onClose);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [autoHideCanceled, setAutoHideCanceled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
const cancelAutoHide = useCallback(() => {
|
||||
if (timeoutRef.current == null) return;
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
setAutoHideCanceled(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || timeout == null || autoHideCanceled) return;
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
timeoutRef.current = null;
|
||||
onCloseRef.current();
|
||||
}, timeout);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current == null) return;
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
};
|
||||
}, [autoHideCanceled, open, timeout]);
|
||||
|
||||
useKey(
|
||||
"Escape",
|
||||
() => {
|
||||
if (!open) return;
|
||||
onClose();
|
||||
},
|
||||
{},
|
||||
[open],
|
||||
);
|
||||
|
||||
const toastIcon = icon === null ? null : (icon ?? (color && color in ICONS && ICONS[color]));
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, right: "-10%" }}
|
||||
animate={{ opacity: 100, right: 0 }}
|
||||
exit={{ opacity: 0, right: "-100%" }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={classNames("bg-surface m-2 rounded-lg")}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
`x-theme-toast x-theme-toast--${color}`,
|
||||
"pointer-events-auto overflow-hidden",
|
||||
"relative pointer-events-auto bg-surface text-text rounded-lg",
|
||||
"border border-border shadow-lg w-100",
|
||||
)}
|
||||
onFocusCapture={cancelAutoHide}
|
||||
onKeyDownCapture={cancelAutoHide}
|
||||
onPointerDownCapture={cancelAutoHide}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
"pl-3 py-3 flex items-start gap-2 w-full overflow-auto",
|
||||
hideDismiss ? "pr-3" : "pr-10",
|
||||
dynamicHeight ? "max-h-[80vh]" : "max-h-44",
|
||||
)}
|
||||
>
|
||||
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />}
|
||||
<VStack space={2} className="w-full min-w-0">
|
||||
<div className="select-auto">{children}</div>
|
||||
{action?.({ hide: onClose })}
|
||||
</VStack>
|
||||
</div>
|
||||
|
||||
{!hideDismiss && (
|
||||
<IconButton
|
||||
color={color}
|
||||
variant="border"
|
||||
className="opacity-60 border-0 absolute! top-2 right-2"
|
||||
title="Dismiss"
|
||||
icon="x"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{timeout != null && !autoHideCanceled && (
|
||||
<div className="w-full absolute bottom-0 left-0 right-0">
|
||||
<m.div
|
||||
className="bg-surface-highlight h-[3px]"
|
||||
initial={{ width: "100%" }}
|
||||
animate={{ width: "0%", opacity: 0.2 }}
|
||||
transition={{ duration: timeout / 1000, ease: "linear" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
export type { ToastProps } from "@yaakapp-internal/ui";
|
||||
export { Toast } from "@yaakapp-internal/ui";
|
||||
@@ -1,41 +1 @@
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Get notified when a mouse click happens outside the target ref
|
||||
* @param ref The element to be notified when a mouse click happens outside it
|
||||
* @param onClickAway
|
||||
* @param ignored Optional outside element to ignore (useful for dropdown triggers)
|
||||
*/
|
||||
export function useClickOutside(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
onClickAway: (event: MouseEvent) => void,
|
||||
ignored?: RefObject<HTMLElement | null>,
|
||||
) {
|
||||
const savedCallback = useRef(onClickAway);
|
||||
|
||||
useEffect(() => {
|
||||
savedCallback.current = onClickAway;
|
||||
}, [onClickAway]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: MouseEvent) => {
|
||||
if (ref.current == null || !(event.target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const isIgnored = ignored?.current?.contains(event.target);
|
||||
const clickedOutside = !ref.current.contains(event.target);
|
||||
if (!isIgnored && clickedOutside) {
|
||||
savedCallback.current(event);
|
||||
}
|
||||
};
|
||||
// NOTE: We're using mousedown instead of click to handle some edge cases like when a context
|
||||
// menu is open with the ctrl key.
|
||||
document.addEventListener("mousedown", handler, { capture: true });
|
||||
document.addEventListener("contextmenu", handler, { capture: true });
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handler);
|
||||
document.removeEventListener("contextmenu", handler);
|
||||
};
|
||||
}, [ignored, ref]);
|
||||
}
|
||||
export { useClickOutside } from "@yaakapp-internal/ui";
|
||||
@@ -1,11 +1,26 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { debounce } from "@yaakapp-internal/lib";
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import {
|
||||
formatHotkey,
|
||||
type HotkeyContextValue,
|
||||
type HotKeyOptions,
|
||||
useFormattedHotkey,
|
||||
useHotKey,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { capitalize } from "../lib/capitalize";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
|
||||
export type { HotKeyOptions };
|
||||
export { useFormattedHotkey, useHotKey };
|
||||
|
||||
declare module "@yaakapp-internal/ui" {
|
||||
interface HotkeyActionRegistry {
|
||||
action: HotkeyAction;
|
||||
}
|
||||
}
|
||||
|
||||
const HOLD_KEYS = ["Shift", "Control", "Command", "Alt", "Meta"];
|
||||
const SINGLE_WHITELIST = ["Delete", "Enter", "Backspace"];
|
||||
|
||||
@@ -188,12 +203,6 @@ export const hotkeyActions: HotkeyAction[] = (
|
||||
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
||||
});
|
||||
|
||||
export type HotKeyOptions = {
|
||||
enable?: boolean | (() => boolean);
|
||||
priority?: number;
|
||||
allowDefault?: boolean;
|
||||
};
|
||||
|
||||
interface Callback {
|
||||
action: HotkeyAction;
|
||||
callback: (e: KeyboardEvent) => void;
|
||||
@@ -210,25 +219,34 @@ const clearCurrentKeysDebounced = debounce(() => {
|
||||
jotaiStore.set(currentKeysAtom, new Set([]));
|
||||
}, 5000);
|
||||
|
||||
export function useHotKey(
|
||||
action: HotkeyAction | null,
|
||||
function registerHotkey(
|
||||
action: HotkeyAction,
|
||||
callback: (e: KeyboardEvent) => void,
|
||||
options: HotKeyOptions = {},
|
||||
options: HotKeyOptions,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (action == null) return;
|
||||
jotaiStore.set(callbacksAtom, (prev) => {
|
||||
const without = prev.filter((cb) => {
|
||||
const isTheSame = cb.action === action && cb.options.priority === options.priority;
|
||||
return !isTheSame;
|
||||
});
|
||||
const newCb: Callback = { action, callback, options };
|
||||
return [...without, newCb];
|
||||
jotaiStore.set(callbacksAtom, (prev) => {
|
||||
const without = prev.filter((cb) => {
|
||||
const isTheSame = cb.action === action && cb.options.priority === options.priority;
|
||||
return !isTheSame;
|
||||
});
|
||||
return () => {
|
||||
jotaiStore.set(callbacksAtom, (prev) => prev.filter((cb) => cb.callback !== callback));
|
||||
};
|
||||
}, [action, callback, options]);
|
||||
const newCb: Callback = { action, callback, options };
|
||||
return [...without, newCb];
|
||||
});
|
||||
return () => {
|
||||
jotaiStore.set(callbacksAtom, (prev) => prev.filter((cb) => cb.callback !== callback));
|
||||
};
|
||||
}
|
||||
|
||||
export function useHotkeyContextValue(): HotkeyContextValue {
|
||||
const hotkeys = useAtomValue(hotkeysAtom);
|
||||
return useMemo(
|
||||
() => ({
|
||||
os: platform.osType(),
|
||||
bindingFor: (action: HotkeyAction) => hotkeys[action] ?? null,
|
||||
register: registerHotkey,
|
||||
}),
|
||||
[hotkeys],
|
||||
);
|
||||
}
|
||||
|
||||
export function useSubscribeHotKeys() {
|
||||
@@ -330,66 +348,7 @@ export function getHotkeyScope(action: HotkeyAction): string {
|
||||
}
|
||||
|
||||
export function formatHotkeyString(trigger: string): string[] {
|
||||
const os = platform.osType();
|
||||
const parts = trigger.split("+");
|
||||
const labelParts: string[] = [];
|
||||
|
||||
for (const p of parts) {
|
||||
if (os === "macos") {
|
||||
if (p === "Meta") {
|
||||
labelParts.push("⌘");
|
||||
} else if (p === "Shift") {
|
||||
labelParts.push("⇧");
|
||||
} else if (p === "Control") {
|
||||
labelParts.push("⌃");
|
||||
} else if (p === "Alt") {
|
||||
labelParts.push("⌥");
|
||||
} else if (p === "Enter") {
|
||||
// U+21A9 has an emoji presentation, which Chromium's font fallback picks
|
||||
// (a blue glyph among monochrome ones). U+FE0E forces the text form.
|
||||
labelParts.push("↩︎");
|
||||
} else if (p === "Tab") {
|
||||
labelParts.push("⇥");
|
||||
} else if (p === "Backspace") {
|
||||
labelParts.push("⌫");
|
||||
} else if (p === "Delete") {
|
||||
labelParts.push("⌦");
|
||||
} else if (p === "Minus") {
|
||||
labelParts.push("-");
|
||||
} else if (p === "Plus") {
|
||||
labelParts.push("+");
|
||||
} else if (p === "Equal") {
|
||||
labelParts.push("=");
|
||||
} else if (p === "Space") {
|
||||
labelParts.push("Space");
|
||||
} else {
|
||||
labelParts.push(capitalize(p));
|
||||
}
|
||||
} else {
|
||||
if (p === "Control") {
|
||||
labelParts.push("Ctrl");
|
||||
} else if (p === "Space") {
|
||||
labelParts.push("Space");
|
||||
} else {
|
||||
labelParts.push(capitalize(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (os === "macos") {
|
||||
return labelParts;
|
||||
}
|
||||
return [labelParts.join("+")];
|
||||
}
|
||||
|
||||
export function useFormattedHotkey(action: HotkeyAction | null): string[] | null {
|
||||
const hotkeys = useAtomValue(hotkeysAtom);
|
||||
const trigger = action != null ? (hotkeys[action]?.[0] ?? null) : null;
|
||||
if (trigger == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return formatHotkeyString(trigger);
|
||||
return formatHotkey(trigger, platform.osType());
|
||||
}
|
||||
|
||||
function compareKeys(keysA: string[], keysB: string[]) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import type { Plugin } from "@yaakapp-internal/models";
|
||||
import { pluginsAtom } from "@yaakapp-internal/models";
|
||||
import type { PluginMetadata } from "@yaakapp-internal/plugins";
|
||||
@@ -24,6 +24,22 @@ export function usePluginInfo(id: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Every plugin with its metadata, once loaded; the ones still loading are left out. */
|
||||
export function usePluginInfos() {
|
||||
const plugins = useAtomValue(pluginsAtom);
|
||||
return useQueries({
|
||||
queries: plugins.map((plugin) => ({
|
||||
queryKey: pluginInfoKey(plugin.id, plugin),
|
||||
queryFn: () => rpc<PluginMetadata>("cmd_plugin_info", { id: plugin.id }),
|
||||
})),
|
||||
combine: (results) =>
|
||||
results.flatMap((result, i) => {
|
||||
const plugin = plugins[i];
|
||||
return result.data == null || plugin == null ? [] : [{ plugin, info: result.data }];
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateAllPluginInfo() {
|
||||
queryClient.invalidateQueries({ queryKey: ["plugin_info"] }).catch(console.error);
|
||||
}
|
||||
@@ -1,8 +1 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
|
||||
export function useRandomKey(initialValue?: string) {
|
||||
const [value, setValue] = useState<string>(initialValue ?? generateId());
|
||||
const regenerate = useCallback(() => setValue(generateId()), []);
|
||||
return [value, regenerate] as const;
|
||||
}
|
||||
export { useRandomKey } from "@yaakapp-internal/ui";
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { uninstallPlugin } from "@yaakapp-internal/plugins";
|
||||
import { InlineCode } from "@yaakapp-internal/ui";
|
||||
import { showConfirmDelete } from "../lib/confirm";
|
||||
import { minPromiseMillis } from "../lib/minPromiseMillis";
|
||||
|
||||
/** Confirms, then uninstalls. Null until there is a plugin to uninstall. */
|
||||
export function usePromptUninstallPlugin(pluginId: string | null, name: string) {
|
||||
const mut = useMutation({
|
||||
mutationKey: ["uninstall_plugin", pluginId],
|
||||
mutationFn: async () => {
|
||||
if (pluginId == null) return;
|
||||
|
||||
const confirmed = await showConfirmDelete({
|
||||
id: `uninstall-plugin-${pluginId}`,
|
||||
title: "Uninstall Plugin",
|
||||
confirmText: "Uninstall",
|
||||
description: (
|
||||
<>
|
||||
Permanently uninstall <InlineCode>{name}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (confirmed) {
|
||||
await minPromiseMillis(uninstallPlugin(pluginId), 700);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return pluginId == null ? null : mut;
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { AGENT_CLIENTS, appSetup, cliSetup } from "./agentSetup";
|
||||
|
||||
const URL = "http://127.0.0.1:64343/mcp";
|
||||
const TOKEN = "abc123";
|
||||
import { AGENT_CLIENTS, cliSetup, skillInstallCommand } from "./agentSetup";
|
||||
|
||||
describe("cliSetup", () => {
|
||||
test("uses each client's own add command", () => {
|
||||
expect(cliSetup("claude_code").snippet).toBe("claude mcp add yaak -- yaak mcp");
|
||||
expect(cliSetup("claude_code").snippet).toBe("claude mcp add -s user yaak -- yaak mcp");
|
||||
expect(cliSetup("codex").snippet).toBe("codex mcp add yaak -- yaak mcp");
|
||||
});
|
||||
|
||||
test("names a file only when the snippet goes in one", () => {
|
||||
expect(cliSetup("claude_code").where).toBeNull();
|
||||
expect(cliSetup("codex").where).toBeNull();
|
||||
expect(cliSetup("cursor").where).toBe("Add to ~/.cursor/mcp.json.");
|
||||
expect(cliSetup("vscode").where).toContain("mcp.json");
|
||||
expect(cliSetup("other").where).not.toBeNull();
|
||||
});
|
||||
|
||||
test("writes the config shape each client reads", () => {
|
||||
expect(JSON.parse(cliSetup("cursor").snippet)).toEqual({
|
||||
mcpServers: { yaak: { command: "yaak", args: ["mcp"] } },
|
||||
@@ -21,42 +26,27 @@ describe("cliSetup", () => {
|
||||
mcpServers: { yaak: { command: "yaak", args: ["mcp"] } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("appSetup", () => {
|
||||
test("passes the token as a bearer header", () => {
|
||||
expect(appSetup("claude_code", URL, TOKEN).snippet).toBe(
|
||||
`claude mcp add --transport http yaak ${URL} --header "Authorization: Bearer ${TOKEN}"`,
|
||||
);
|
||||
expect(appSetup("codex", URL, TOKEN).snippet).toBe(
|
||||
`[mcp_servers.yaak]\nurl = "${URL}"\nhttp_headers = { "Authorization" = "Bearer ${TOKEN}" }`,
|
||||
);
|
||||
expect(JSON.parse(appSetup("cursor", URL, TOKEN).snippet)).toEqual({
|
||||
mcpServers: { yaak: { url: URL, headers: { Authorization: `Bearer ${TOKEN}` } } },
|
||||
});
|
||||
expect(JSON.parse(appSetup("vscode", URL, TOKEN).snippet)).toEqual({
|
||||
servers: { yaak: { type: "http", url: URL, headers: { Authorization: `Bearer ${TOKEN}` } } },
|
||||
});
|
||||
expect(JSON.parse(appSetup("other", URL, TOKEN).snippet)).toEqual({
|
||||
mcpServers: {
|
||||
yaak: {
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-remote", URL, "--header", `Authorization: Bearer ${TOKEN}`],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps argument lists on one line", () => {
|
||||
expect(appSetup("other", URL, TOKEN).snippet).toContain(
|
||||
`"args": ["-y", "mcp-remote", "${URL}", "--header", "Authorization: Bearer ${TOKEN}"]`,
|
||||
);
|
||||
expect(cliSetup("cursor").snippet).toContain(`"args": ["mcp"]`);
|
||||
});
|
||||
|
||||
test("covers every listed client", () => {
|
||||
for (const { value } of AGENT_CLIENTS) {
|
||||
expect(cliSetup(value).snippet).not.toBe("");
|
||||
expect(appSetup(value, URL, TOKEN).snippet).toContain(TOKEN);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("skillInstallCommand", () => {
|
||||
test("targets the agents the installer knows by name", () => {
|
||||
expect(skillInstallCommand("claude_code")).toBe("yaak agent install --agent claude-code");
|
||||
expect(skillInstallCommand("codex")).toBe("yaak agent install --agent codex");
|
||||
expect(skillInstallCommand("cursor")).toBe("yaak agent install --agent cursor");
|
||||
});
|
||||
|
||||
test("installs everywhere for agents it cannot target", () => {
|
||||
expect(skillInstallCommand("vscode")).toBe("yaak agent install");
|
||||
expect(skillInstallCommand("other")).toBe("yaak agent install");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
export type AgentClient = "claude_code" | "codex" | "cursor" | "vscode" | "other";
|
||||
import type { AgentClient } from "@yaakapp-internal/rpc-schema";
|
||||
|
||||
export type { AgentClient };
|
||||
|
||||
export const AGENT_CLIENTS: { label: string; value: AgentClient }[] = [
|
||||
{ label: "Claude Code", value: "claude_code" },
|
||||
@@ -9,21 +11,34 @@ export const AGENT_CLIENTS: { label: string; value: AgentClient }[] = [
|
||||
];
|
||||
|
||||
export interface AgentSetup {
|
||||
/** Where the snippet goes, as a sentence */
|
||||
where: string;
|
||||
/** The file the snippet goes in, as a sentence, when it is a file rather than a command */
|
||||
where: string | null;
|
||||
snippet: string;
|
||||
language: "shell" | "json" | "text";
|
||||
}
|
||||
|
||||
const RUN_IN_TERMINAL = "Run in a terminal.";
|
||||
/** `yaak agent install`, targeted at `client` when the installer knows it by name. */
|
||||
export function skillInstallCommand(client: AgentClient): string {
|
||||
switch (client) {
|
||||
case "claude_code":
|
||||
return "yaak agent install --agent claude-code";
|
||||
case "codex":
|
||||
return "yaak agent install --agent codex";
|
||||
case "cursor":
|
||||
return "yaak agent install --agent cursor";
|
||||
case "vscode":
|
||||
case "other":
|
||||
return "yaak agent install";
|
||||
}
|
||||
}
|
||||
|
||||
/** Setup for `yaak mcp`, which the client launches over stdio. */
|
||||
export function cliSetup(client: AgentClient): AgentSetup {
|
||||
switch (client) {
|
||||
case "claude_code":
|
||||
return { where: RUN_IN_TERMINAL, snippet: "claude mcp add yaak -- yaak mcp", language: "shell" };
|
||||
return { where: null, snippet: "claude mcp add -s user yaak -- yaak mcp", language: "shell" };
|
||||
case "codex":
|
||||
return { where: RUN_IN_TERMINAL, snippet: "codex mcp add yaak -- yaak mcp", language: "shell" };
|
||||
return { where: null, snippet: "codex mcp add yaak -- yaak mcp", language: "shell" };
|
||||
case "cursor":
|
||||
return {
|
||||
where: "Add to ~/.cursor/mcp.json.",
|
||||
@@ -45,56 +60,6 @@ export function cliSetup(client: AgentClient): AgentSetup {
|
||||
}
|
||||
}
|
||||
|
||||
/** Setup for the app's MCP server at `url`, which needs `token` as a bearer token. */
|
||||
export function appSetup(client: AgentClient, url: string, token: string): AgentSetup {
|
||||
const authorization = `Bearer ${token}`;
|
||||
switch (client) {
|
||||
case "claude_code":
|
||||
return {
|
||||
where: RUN_IN_TERMINAL,
|
||||
snippet: `claude mcp add --transport http yaak ${url} --header "Authorization: ${authorization}"`,
|
||||
language: "shell",
|
||||
};
|
||||
case "codex":
|
||||
return {
|
||||
where: "Add to ~/.codex/config.toml.",
|
||||
snippet: [
|
||||
"[mcp_servers.yaak]",
|
||||
`url = ${JSON.stringify(url)}`,
|
||||
`http_headers = { "Authorization" = ${JSON.stringify(authorization)} }`,
|
||||
].join("\n"),
|
||||
language: "text",
|
||||
};
|
||||
case "cursor":
|
||||
return {
|
||||
where: "Add to ~/.cursor/mcp.json.",
|
||||
snippet: json({ mcpServers: { yaak: { url, headers: { Authorization: authorization } } } }),
|
||||
language: "json",
|
||||
};
|
||||
case "vscode":
|
||||
return {
|
||||
where: "Add to your user mcp.json (MCP: Open User Configuration).",
|
||||
snippet: json({
|
||||
servers: { yaak: { type: "http", url, headers: { Authorization: authorization } } },
|
||||
}),
|
||||
language: "json",
|
||||
};
|
||||
case "other":
|
||||
return {
|
||||
where: "For clients that read an mcpServers config, through the mcp-remote bridge.",
|
||||
snippet: json({
|
||||
mcpServers: {
|
||||
yaak: {
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-remote", url, "--header", `Authorization: ${authorization}`],
|
||||
},
|
||||
},
|
||||
}),
|
||||
language: "json",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Pretty JSON with arrays of plain values kept on one line. */
|
||||
function json(value: unknown): string {
|
||||
return JSON.stringify(value, null, 2).replace(
|
||||
|
||||
@@ -1,22 +1 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Get the text content from a ReactNode
|
||||
* https://stackoverflow.com/questions/50428910/get-text-content-from-node-in-react
|
||||
*/
|
||||
export function getNodeText(node: ReactNode): string {
|
||||
if (typeof node === "string" || typeof node === "number") {
|
||||
return String(node);
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(getNodeText).join("");
|
||||
}
|
||||
|
||||
if (typeof node === "object" && node) {
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
return getNodeText((node as any).props.children);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
export { getNodeText } from "@yaakapp-internal/ui";
|
||||
@@ -1,43 +1,9 @@
|
||||
import type { AnyModel } from "@yaakapp-internal/models";
|
||||
import { foldersAtom } from "@yaakapp-internal/models";
|
||||
import { resolvedModelName } from "@yaakapp-internal/ui/resolvedModelName";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { replaceTemplateTags } from "./templateTags";
|
||||
|
||||
export function resolvedModelName(r: AnyModel | null): string {
|
||||
if (r == null) return "";
|
||||
|
||||
if (!("url" in r) || r.model === "plugin") {
|
||||
return "name" in r ? r.name : "";
|
||||
}
|
||||
|
||||
// Return name if it has one
|
||||
if ("name" in r && r.name) {
|
||||
return r.name;
|
||||
}
|
||||
|
||||
// Replace variable syntax with variable name
|
||||
const withoutVariables = replaceTemplateTags(r.url, (m) => m.inner.trim());
|
||||
if (withoutVariables.trim() === "") {
|
||||
return r.model === "http_request"
|
||||
? r.bodyType && r.bodyType === "graphql"
|
||||
? "GraphQL Request"
|
||||
: "HTTP Request"
|
||||
: r.model === "websocket_request"
|
||||
? "WebSocket Request"
|
||||
: "gRPC Request";
|
||||
}
|
||||
|
||||
// GRPC gets nice short names
|
||||
if (r.model === "grpc_request" && r.service != null && r.method != null) {
|
||||
const shortService = r.service.split(".").pop();
|
||||
return `${shortService}/${r.method}`;
|
||||
}
|
||||
|
||||
// Strip unnecessary protocol
|
||||
const withoutProto = withoutVariables.replace(/^(http|https|ws|wss):\/\//, "");
|
||||
|
||||
return withoutProto;
|
||||
}
|
||||
export { resolvedModelName };
|
||||
|
||||
export function resolvedModelNameWithFolders(model: AnyModel | null): string {
|
||||
return resolvedModelNameWithFoldersArray(model).join(" / ");
|
||||
|
||||
@@ -1,137 +1,2 @@
|
||||
/*
|
||||
* Finding template tags in text.
|
||||
*
|
||||
* A tag closes at `]}`, but a quoted argument is allowed to contain `]}` of its own — the
|
||||
* printer in `crates/yaak-templates` emits `${[ fn(a='x]}y') ]}` verbatim — so the scan has to
|
||||
* step over quoted strings. It mirrors the Lezer grammar in
|
||||
* `components/core/Editor/twig/twig.grammar`: a terminated `'…'` (where `\` escapes whatever
|
||||
* follows it) is consumed whole, and an unterminated quote is just another character, so a
|
||||
* half-typed tag still closes at the next `]}` instead of swallowing the rest of the document.
|
||||
*
|
||||
* This was a regex until CodeQL flagged it (js/polynomial-redos): matching an unterminated
|
||||
* quote needed a negative lookahead that rescanned to the end of the input at every unpaired
|
||||
* quote, which is quadratic on text an attacker picks. The scan below is linear instead — see
|
||||
* the notes on `closingQuoteTable` and on the dead-end set in `findTemplateTags` for why.
|
||||
*/
|
||||
|
||||
const QUOTE = 0x27; // '
|
||||
const BACKSLASH = 0x5c; // \
|
||||
const CLOSE_BRACKET = 0x5d; // ]
|
||||
const CLOSE_BRACE = 0x7d; // }
|
||||
|
||||
/** The `${[`…`]}` bounds of one tag, and what sits between them. */
|
||||
export interface TemplateTagMatch {
|
||||
/** Index of the `$` that opens the tag. */
|
||||
start: number;
|
||||
/** Index just past the closing `]}`. */
|
||||
end: number;
|
||||
/** Everything between `${[` and `]}`, not trimmed. */
|
||||
inner: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* For every index `i`, the index of the `'` that closes a string whose body starts at `i`, or
|
||||
* `-1` when nothing does.
|
||||
*
|
||||
* Built right to left in a single pass, which is the trick that replaces the regex lookahead:
|
||||
* whether a quote opens a terminated string depends only on the text after it, so the answer
|
||||
* can be computed once for every position instead of re-derived at each quote. Skipping a
|
||||
* string of any length then costs one lookup.
|
||||
*/
|
||||
function closingQuoteTable(text: string): Int32Array {
|
||||
// Two extra slots so a `\` in the last position can read past the end and find `-1`
|
||||
const table = new Int32Array(text.length + 2).fill(-1);
|
||||
for (let i = text.length - 1; i >= 0; i--) {
|
||||
const c = text.charCodeAt(i);
|
||||
// A backslash escapes whatever follows it, including a quote
|
||||
const next = c === BACKSLASH ? table[i + 2] : table[i + 1];
|
||||
table[i] = c === QUOTE ? i : (next ?? -1);
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds every template tag in `text`, in order and without overlaps.
|
||||
*
|
||||
* A `${[` with no `]}` after it isn't a tag, and neither is a `${[` nested inside a tag body —
|
||||
* both are ordinary content, same as the regex this replaced treated them.
|
||||
*/
|
||||
export function findTemplateTags(text: string): TemplateTagMatch[] {
|
||||
const matches: TemplateTagMatch[] = [];
|
||||
|
||||
// Both built only once a scan actually needs them, so short values allocate nothing
|
||||
let closingQuotes: Int32Array | null = null;
|
||||
|
||||
/*
|
||||
* Body positions a previous scan already ran off the end of the text from. Where a scan ends
|
||||
* up depends only on where it starts, so a scan that walks into one of these would repeat
|
||||
* that same walk and fail the same way, and can stop right there.
|
||||
*
|
||||
* That bound is what keeps the whole pass linear: every position enters this set at most
|
||||
* once, and every step of every scan either visits a position for the first time or ends
|
||||
* that scan.
|
||||
*/
|
||||
let deadEnds: Set<number> | null = null;
|
||||
|
||||
let searchFrom = 0;
|
||||
while (searchFrom < text.length) {
|
||||
const start = text.indexOf("${[", searchFrom);
|
||||
if (start < 0) break;
|
||||
|
||||
const visited: number[] = [];
|
||||
let end = -1;
|
||||
let i = start + 3;
|
||||
while (i < text.length) {
|
||||
if (deadEnds?.has(i)) break;
|
||||
visited.push(i);
|
||||
|
||||
const c = text.charCodeAt(i);
|
||||
if (c === QUOTE) {
|
||||
closingQuotes ??= closingQuoteTable(text);
|
||||
const close = closingQuotes[i + 1] ?? -1;
|
||||
// An unterminated quote is an ordinary character, so only step over a closed string
|
||||
i = close < 0 ? i + 1 : close + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c === CLOSE_BRACKET && text.charCodeAt(i + 1) === CLOSE_BRACE) {
|
||||
end = i + 2;
|
||||
break;
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if (end < 0) {
|
||||
// Not a tag. Resume past the opener: a real tag may start inside a string this scan
|
||||
// stepped over.
|
||||
deadEnds ??= new Set();
|
||||
for (const position of visited) deadEnds.add(position);
|
||||
searchFrom = start + 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
matches.push({ start, end, inner: text.slice(start + 3, end - 2) });
|
||||
searchFrom = end;
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/** Rewrites every template tag in `text` with whatever `replace` returns for it. */
|
||||
export function replaceTemplateTags(
|
||||
text: string,
|
||||
replace: (match: TemplateTagMatch) => string,
|
||||
): string {
|
||||
const matches = findTemplateTags(text);
|
||||
if (matches.length === 0) return text;
|
||||
|
||||
let result = "";
|
||||
let copiedTo = 0;
|
||||
for (const match of matches) {
|
||||
result += text.slice(copiedTo, match.start) + replace(match);
|
||||
copiedTo = match.end;
|
||||
}
|
||||
|
||||
return result + text.slice(copiedTo);
|
||||
}
|
||||
export type { TemplateTagMatch } from "@yaakapp-internal/ui/templateTags";
|
||||
export { findTemplateTags, replaceTemplateTags } from "@yaakapp-internal/ui/templateTags";
|
||||
@@ -1,50 +1,25 @@
|
||||
import { atom } from "jotai";
|
||||
import type { ToastInstance } from "../components/Toasts";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
// The toast store alone, without the components, which tests in a plain runtime can't load
|
||||
import {
|
||||
hideToast as hideToastIn,
|
||||
hideToastById as hideToastByIdIn,
|
||||
type ShowToastArgs,
|
||||
showToast as showToastIn,
|
||||
type ToastInstance,
|
||||
} from "@yaakapp-internal/ui/toast";
|
||||
import { jotaiStore } from "./jotai";
|
||||
|
||||
export const toastsAtom = atom<ToastInstance[]>([]);
|
||||
export { toastsAtom } from "@yaakapp-internal/ui/toast";
|
||||
|
||||
export function showToast({
|
||||
id,
|
||||
timeout = 5000,
|
||||
...props
|
||||
}: Omit<ToastInstance, "id" | "timeout" | "uniqueKey"> & {
|
||||
id?: ToastInstance["id"];
|
||||
timeout?: ToastInstance["timeout"];
|
||||
}) {
|
||||
id = id ?? generateId();
|
||||
const uniqueKey = generateId();
|
||||
|
||||
const toasts = jotaiStore.get(toastsAtom);
|
||||
const toastWithSameId = toasts.find((t) => t.id === id);
|
||||
|
||||
let delay = 0;
|
||||
if (toastWithSameId) {
|
||||
hideToast(toastWithSameId);
|
||||
// Allow enough time for old toast to animate out
|
||||
delay = 200;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const newToast: ToastInstance = { id, uniqueKey, timeout, ...props };
|
||||
jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]);
|
||||
}, delay);
|
||||
|
||||
return id;
|
||||
export function showToast(args: ShowToastArgs) {
|
||||
return showToastIn(jotaiStore, args);
|
||||
}
|
||||
|
||||
export function hideToastById(id: string) {
|
||||
const toast = jotaiStore.get(toastsAtom).find((t) => t.id === id);
|
||||
if (toast) hideToast(toast);
|
||||
hideToastByIdIn(jotaiStore, id);
|
||||
}
|
||||
|
||||
export function hideToast(toHide: ToastInstance) {
|
||||
jotaiStore.set(toastsAtom, (all) => {
|
||||
const t = all.find((t) => t.uniqueKey === toHide.uniqueKey);
|
||||
t?.onClose?.();
|
||||
return all.filter((t) => t.uniqueKey !== toHide.uniqueKey);
|
||||
});
|
||||
hideToastIn(jotaiStore, toHide);
|
||||
}
|
||||
|
||||
export function showErrorToast<T>({
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { HotkeyProvider } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import { LazyMotion, MotionConfig } from "motion/react";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { lazy, type ReactNode, Suspense } from "react";
|
||||
import { GlobalHooks } from "../components/GlobalHooks";
|
||||
import RouteError from "../components/RouteError";
|
||||
import { useHotkeyContextValue } from "../hooks/useHotKey";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { queryClient } from "../lib/queryClient";
|
||||
|
||||
@@ -27,22 +29,28 @@ function RouteComponent() {
|
||||
return (
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LazyMotion strict features={motionFeatures}>
|
||||
<MotionConfig transition={{ duration: 0.1 }}>
|
||||
<Suspense>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
<ContextMenus />
|
||||
</Suspense>
|
||||
<Layout />
|
||||
<GlobalHooks />
|
||||
</MotionConfig>
|
||||
</LazyMotion>
|
||||
<Hotkeys>
|
||||
<LazyMotion strict features={motionFeatures}>
|
||||
<MotionConfig transition={{ duration: 0.1 }}>
|
||||
<Suspense>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
<ContextMenus />
|
||||
</Suspense>
|
||||
<Layout />
|
||||
<GlobalHooks />
|
||||
</MotionConfig>
|
||||
</LazyMotion>
|
||||
</Hotkeys>
|
||||
</QueryClientProvider>
|
||||
</JotaiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Hotkeys({ children }: { children: ReactNode }) {
|
||||
return <HotkeyProvider value={useHotkeyContextValue()}>{children}</HotkeyProvider>;
|
||||
}
|
||||
|
||||
function Layout() {
|
||||
return (
|
||||
<div
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -0,0 +1,49 @@
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useState } from "react";
|
||||
import { rpc } from "../lib/rpc";
|
||||
|
||||
/**
|
||||
* Icons by app path, as data URLs once loaded, for the whole session. Plain rather than a query
|
||||
* per icon, since every new row in the list shows one.
|
||||
*/
|
||||
const icons = new Map<string, string | null>();
|
||||
const loading = new Map<string, Promise<string | null>>();
|
||||
|
||||
function loadIcon(path: string): Promise<string | null> {
|
||||
let promise = loading.get(path);
|
||||
if (promise == null) {
|
||||
promise = rpc("app_icon", { path })
|
||||
.then((png) => (png == null ? null : `data:image/png;base64,${png}`))
|
||||
.catch(() => null)
|
||||
.then((url) => {
|
||||
icons.set(path, url);
|
||||
return url;
|
||||
});
|
||||
loading.set(path, promise);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** An app's icon, read once per path for the whole session */
|
||||
export function AppIcon({ path, className }: { path: string; className?: string }) {
|
||||
const [url, setUrl] = useState(() => icons.get(path));
|
||||
useEffect(() => {
|
||||
if (icons.has(path)) {
|
||||
setUrl(icons.get(path));
|
||||
return;
|
||||
}
|
||||
let current = true;
|
||||
void loadIcon(path).then((loaded) => {
|
||||
if (current) setUrl(loaded);
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [path]);
|
||||
|
||||
return url == null ? (
|
||||
<span aria-hidden className={classNames(className, "inline-block w-4 h-4 shrink-0")} />
|
||||
) : (
|
||||
<img alt="" src={url} className={classNames(className, "w-4 h-4 shrink-0")} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Dropdown, type DropdownItem, IconButton } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
import { flowTotalAtom } from "../lib/store";
|
||||
|
||||
/** Deletes every flow, once confirmed in its menu, which its hotkey opens too */
|
||||
export function ClearFlowsButton() {
|
||||
const total = useAtomValue(flowTotalAtom);
|
||||
const clear = useRpcMutation("clear_flows");
|
||||
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
type: "content",
|
||||
label: (
|
||||
<div className="px-1.5 py-1 max-w-[17rem]">
|
||||
<div className="text-sm text-text">
|
||||
{total === 1 ? "Clear 1 flow?" : `Clear all ${total} flows?`}
|
||||
</div>
|
||||
<div className="text-xs text-text-subtle mt-0.5">
|
||||
{clear.error != null ? (
|
||||
<span className="text-danger">{clear.error.message}</span>
|
||||
) : (
|
||||
"They are deleted from disk with their bodies, for good."
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Clear",
|
||||
icon: "trash",
|
||||
color: "danger",
|
||||
onSelect: () => clear.mutate({}),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown items={items} hotKeyAction="flows_clear">
|
||||
<IconButton
|
||||
className="pointer-events-auto"
|
||||
icon="trash"
|
||||
title="Clear flows"
|
||||
size="sm"
|
||||
iconColor="secondary"
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
TruncatedWideTableCell,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
|
||||
interface Props {
|
||||
exchanges: Flow[];
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ExchangesTable({ exchanges, style, className }: Props) {
|
||||
if (exchanges.length === 0) {
|
||||
return <p className="text-text-subtlest text-sm">No traffic yet</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<Table scrollable className="px-2">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Method</TableHeaderCell>
|
||||
<TableHeaderCell>URL</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Type</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{exchanges.map((ex) => (
|
||||
<TableRow key={ex.id}>
|
||||
<TableCell className="font-mono text-2xs">{ex.method}</TableCell>
|
||||
<TruncatedWideTableCell className="font-mono text-2xs">
|
||||
{ex.url}
|
||||
</TruncatedWideTableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={ex.status} error={ex.error} />
|
||||
</TableCell>
|
||||
<TableCell className="text-text-subtle text-xs">
|
||||
{getContentType(ex.contentType)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status, error }: { status: number | null; error: string | null }) {
|
||||
if (error) return <span className="text-xs text-danger">Error</span>;
|
||||
if (status == null) return <span className="text-xs text-text-subtlest">—</span>;
|
||||
|
||||
const color =
|
||||
status >= 500
|
||||
? "text-danger"
|
||||
: status >= 400
|
||||
? "text-warning"
|
||||
: status >= 300
|
||||
? "text-notice"
|
||||
: "text-success";
|
||||
|
||||
return <span className={classNames("text-xs font-mono", color)}>{status}</span>;
|
||||
}
|
||||
|
||||
function getContentType(ct: string | null): string {
|
||||
if (ct == null) return "—";
|
||||
// Strip parameters (e.g. "; charset=utf-8")
|
||||
return ct.split(";")[0]?.trim() ?? ct;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { Button, Icon, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type { ReactNode } from "react";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
import { proxyStateAtom } from "../lib/store";
|
||||
import { useCopyEnvironment, useProxySetup, useSystemProxySwitch } from "./ProxyStatus";
|
||||
|
||||
type StepState = "todo" | "next" | "wait" | "done" | "fail";
|
||||
|
||||
/**
|
||||
* The list's empty state until traffic arrives: trust the certificate, turn on the system proxy,
|
||||
* then see traffic. Not dismissable and nothing remembered; the first flow replaces it. Copy
|
||||
* environment beside it serves someone who wants one terminal proxied and nothing system-wide.
|
||||
*/
|
||||
export function FirstRun() {
|
||||
const proxyState = useAtomValue(proxyStateAtom);
|
||||
const setup = useProxySetup();
|
||||
const { caStatus, trust, systemProxy, setSystemProxy } = setup;
|
||||
const systemSwitch = useSystemProxySwitch(setup);
|
||||
const execute = useRpcMutation("execute_action");
|
||||
const retry = useRpcMutation("take_over");
|
||||
const copyEnvironment = useCopyEnvironment();
|
||||
|
||||
const running = proxyState?.state === "running";
|
||||
// Changed only by the process that owns the engine, so not from here while the CLI does.
|
||||
const otherOwner = proxyState?.otherOwner != null;
|
||||
const recording = proxyState?.recording !== false;
|
||||
const trusted = caStatus.data?.trusted === true;
|
||||
const systemSupported = systemProxy.data?.supported !== false;
|
||||
const systemOn = systemProxy.data?.enabled === true;
|
||||
|
||||
const trustState: StepState = trusted
|
||||
? "done"
|
||||
: trust.isPending
|
||||
? "wait"
|
||||
: trust.error != null
|
||||
? "fail"
|
||||
: "next";
|
||||
const systemState: StepState = setSystemProxy.isPending
|
||||
? "wait"
|
||||
: systemOn
|
||||
? "done"
|
||||
: systemSwitch.failed
|
||||
? "fail"
|
||||
: trusted
|
||||
? "next"
|
||||
: "todo";
|
||||
const ready = trusted && (systemOn || !systemSupported);
|
||||
const trafficState: StepState = !running && !otherOwner ? "fail" : ready ? "wait" : "todo";
|
||||
|
||||
return (
|
||||
<div className="grid place-items-center h-full px-10 pb-16 pt-6">
|
||||
<div className="w-full max-w-[40rem]">
|
||||
<div className="text-xl font-semibold mb-1.5">Set up Yaak Proxy</div>
|
||||
<div className="text-text-subtle text-sm leading-relaxed mb-4">
|
||||
Three steps, once. Afterwards every app on this Mac sends its traffic here, and HTTPS
|
||||
reads as plain text.
|
||||
</div>
|
||||
<div className="rounded-lg border border-border-subtle bg-surface divide-y divide-border-subtle">
|
||||
<Step
|
||||
n={1}
|
||||
state={trustState}
|
||||
title={trusted ? "Certificate trusted" : "Trust the certificate"}
|
||||
description={
|
||||
trusted
|
||||
? "Yaak Proxy CA, in your login keychain."
|
||||
: trustState === "wait"
|
||||
? "Waiting for your password in the macOS prompt."
|
||||
: trustState === "fail"
|
||||
? (trust.error?.message ?? "The certificate was not trusted.")
|
||||
: "So HTTPS reads as plain text. macOS asks for your login password once."
|
||||
}
|
||||
action={
|
||||
trustState === "next" || trustState === "fail" ? (
|
||||
<Button
|
||||
size="2xs"
|
||||
variant={trustState === "fail" ? "border" : "solid"}
|
||||
color={trustState === "fail" ? "default" : "primary"}
|
||||
onClick={() => trust.mutate({})}
|
||||
>
|
||||
{trustState === "fail" ? "Try again" : "Trust certificate"}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{systemSupported && (
|
||||
<Step
|
||||
n={2}
|
||||
state={systemState}
|
||||
title={systemOn ? "System proxy on" : "Turn on the system proxy"}
|
||||
description={
|
||||
otherOwner && !systemOn
|
||||
? proxyState?.otherOwner?.owner === "cli"
|
||||
? "The CLI owns the proxy. Take over to turn this on from here."
|
||||
: "Another Yaak Proxy window owns the engine. Quit it to use this one."
|
||||
: systemSwitch.description
|
||||
}
|
||||
action={
|
||||
(systemState === "next" || systemState === "fail") && running ? (
|
||||
<Button
|
||||
size="2xs"
|
||||
variant={systemState === "fail" ? "border" : "solid"}
|
||||
color={systemState === "fail" ? "default" : "primary"}
|
||||
onClick={() => setSystemProxy.mutate({ enabled: true })}
|
||||
>
|
||||
{systemState === "fail" ? "Try again" : "Turn on"}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Step
|
||||
n={systemSupported ? 3 : 2}
|
||||
state={trafficState}
|
||||
title={trafficState === "wait" ? "Waiting for traffic" : "See traffic"}
|
||||
description={
|
||||
trafficState === "fail"
|
||||
? (retry.error?.message ?? proxyState?.startError ?? "The proxy has not started.")
|
||||
: !recording
|
||||
? "Recording is paused, so traffic passes through without being stored."
|
||||
: trafficState === "wait"
|
||||
? "Open a website or send a request, and its flow appears here."
|
||||
: "Flows appear here as apps send requests."
|
||||
}
|
||||
action={
|
||||
trafficState === "fail" ? (
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
isLoading={retry.isPending}
|
||||
onClick={() => retry.mutate({})}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
) : !recording && running ? (
|
||||
<Button
|
||||
size="2xs"
|
||||
color="primary"
|
||||
onClick={() => execute.mutate({ scope: "global", action: "recording_toggle" })}
|
||||
>
|
||||
Resume recording
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-4 text-sm text-text-subtle">
|
||||
<span>Only need one terminal or one app? Copy the environment instead.</span>
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
className="ml-auto shrink-0"
|
||||
leftSlot={<Icon icon="square_terminal" size="sm" />}
|
||||
onClick={() => void copyEnvironment()}
|
||||
>
|
||||
Copy environment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Step({
|
||||
n,
|
||||
state,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
n: number;
|
||||
state: StepState;
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
action: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[1.4rem_minmax(0,1fr)_auto] gap-x-3 items-center px-4 py-3">
|
||||
<StepMark n={n} state={state} />
|
||||
<div className="min-w-0">
|
||||
<div className={classNames("text-sm", state === "todo" ? "text-text-subtle" : "text-text")}>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"text-xs mt-0.5",
|
||||
state === "fail" ? "text-danger" : "text-text-subtle",
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
<div>{action}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepMark({ n, state }: { n: number; state: StepState }) {
|
||||
const base = "w-[1.4rem] h-[1.4rem] rounded-full grid place-items-center text-xs";
|
||||
switch (state) {
|
||||
case "done":
|
||||
return (
|
||||
<span className={classNames(base, "bg-success/15 text-success")}>
|
||||
<Icon icon="check" size="sm" />
|
||||
</span>
|
||||
);
|
||||
case "fail":
|
||||
return (
|
||||
<span className={classNames(base, "bg-danger/15 text-danger")}>
|
||||
<Icon icon="x" size="sm" />
|
||||
</span>
|
||||
);
|
||||
case "wait":
|
||||
return (
|
||||
<span className={classNames(base, "text-text-subtle")}>
|
||||
<LoadingIcon size="sm" />
|
||||
</span>
|
||||
);
|
||||
case "next":
|
||||
return <span className={classNames(base, "bg-primary text-white font-semibold")}>{n}</span>;
|
||||
case "todo":
|
||||
return <span className={classNames(base, "border border-border text-text-subtle")}>{n}</span>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import type { Flow, FlowBody } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
Banner,
|
||||
CsvViewer,
|
||||
ImageViewer,
|
||||
languageFromContentType,
|
||||
LoadingIcon,
|
||||
MultipartViewer,
|
||||
SvgViewer,
|
||||
TextViewer,
|
||||
WebPageViewer,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
base64ToBytes,
|
||||
bytesToText,
|
||||
filterText,
|
||||
findHeader,
|
||||
formatText,
|
||||
mimeEssence,
|
||||
} from "../lib/body";
|
||||
import { rpc } from "../lib/rpc";
|
||||
|
||||
interface Props {
|
||||
flow: Flow;
|
||||
which: FlowBody;
|
||||
pretty: boolean;
|
||||
}
|
||||
|
||||
export function useFlowBody(flow: Flow, which: FlowBody, enabled = true) {
|
||||
return useQuery({
|
||||
enabled,
|
||||
queryKey: ["get_flow_body", flow.id, which, flow.updatedAt],
|
||||
queryFn: async () => {
|
||||
const res = await rpc("get_flow_body", { flowId: flow.id, which });
|
||||
return {
|
||||
bytes: base64ToBytes(res.data),
|
||||
truncated: res.truncated,
|
||||
storedSize: res.storedSize,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function FlowBodyViewer({ flow, which, pretty }: Props) {
|
||||
const body = useFlowBody(flow, which);
|
||||
|
||||
if (body.isPending) {
|
||||
return (
|
||||
<EmptyState>
|
||||
<LoadingIcon />
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
if (body.error) {
|
||||
return <Banner color="danger">{body.error.message}</Banner>;
|
||||
}
|
||||
|
||||
const { bytes, truncated, storedSize } = body.data;
|
||||
if (bytes.length === 0) {
|
||||
return <EmptyState>{which === "request" ? "No request body" : "Empty"}</EmptyState>;
|
||||
}
|
||||
|
||||
const headers = which === "request" ? flow.requestHeaders : flow.responseHeaders;
|
||||
const contentType = findHeader(headers, "content-type");
|
||||
const mime = mimeEssence(contentType) ?? "";
|
||||
const wireSize = which === "request" ? flow.requestSize : flow.responseSize;
|
||||
const wireTruncated = which === "request" ? flow.requestTruncated : flow.responseTruncated;
|
||||
|
||||
let viewer: ReactNode;
|
||||
if (mime.startsWith("image/svg")) {
|
||||
viewer = <SvgViewer text={bytesToText(bytes, truncated) ?? ""} />;
|
||||
} else if (mime.startsWith("image/")) {
|
||||
viewer = <ImageBody bytes={bytes} mime={mime} />;
|
||||
} else if (mime.startsWith("multipart/") && pretty) {
|
||||
const boundary = contentType?.match(/;\s*boundary="?([^";]+)"?/i)?.[1] ?? "unknown";
|
||||
viewer = (
|
||||
<MultipartViewer
|
||||
data={bytes}
|
||||
boundary={boundary}
|
||||
idPrefix={`${flow.id}.${which}`}
|
||||
textViewerProps={{ formatText }}
|
||||
/>
|
||||
);
|
||||
} else if (/csv|tab-separated/.test(mime) && pretty) {
|
||||
viewer = <CsvViewer text={bytesToText(bytes, truncated)} className="pb-2" />;
|
||||
} else {
|
||||
const text = bytesToText(bytes, truncated);
|
||||
const language = languageFromContentType(contentType, text);
|
||||
if (text == null) {
|
||||
viewer = <EmptyState>Binary body, {formatSize(bytes.length)}</EmptyState>;
|
||||
} else if (language === "html" && pretty && which === "response") {
|
||||
viewer = <WebPageViewer html={text} baseUrl={flow.url} />;
|
||||
} else {
|
||||
viewer = (
|
||||
<FlowTextViewer flow={flow} which={which} text={text} language={language} pretty={pretty} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!truncated) return viewer;
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)] gap-2">
|
||||
<Banner color="notice" className="text-sm">
|
||||
{wireTruncated
|
||||
? `Showing the first ${formatSize(storedSize)} of ${formatSize(wireSize)}, the capture limit. The app received all of it.`
|
||||
: `Showing the first ${formatSize(bytes.length)} of the decoded body, the capture limit. The app received all of it.`}
|
||||
</Banner>
|
||||
<div className="min-h-0">{viewer}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A text body, filtered with the syntax for the language detected from it, not just its header */
|
||||
function FlowTextViewer({
|
||||
flow,
|
||||
which,
|
||||
text,
|
||||
language,
|
||||
pretty,
|
||||
}: Props & { text: string; language: CodeEditorProps["language"] }) {
|
||||
const runFilter = useCallback(
|
||||
(expression: string) => filterText(flow.id, which, expression, language),
|
||||
[flow.id, which, language],
|
||||
);
|
||||
|
||||
return (
|
||||
<TextViewer
|
||||
text={text}
|
||||
language={language}
|
||||
stateKey={`flow.${flow.id}.${which}`}
|
||||
pretty={pretty}
|
||||
className="-mr-2 bg-surface"
|
||||
formatText={formatText}
|
||||
filterText={runFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageBody({ bytes, mime }: { bytes: Uint8Array<ArrayBuffer>; mime: string }) {
|
||||
const [size, setSize] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
createImageBitmap(new Blob([bytes], { type: mime })).then(
|
||||
(bitmap) => {
|
||||
if (!cancelled) setSize({ width: bitmap.width, height: bitmap.height });
|
||||
bitmap.close();
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) setSize(null);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [bytes, mime]);
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[minmax(0,1fr)_auto] gap-2 pb-2">
|
||||
<div className="min-h-0 grid place-items-center rounded-lg border border-border-subtle overflow-hidden p-3 bg-[repeating-conic-gradient(var(--color-surface-highlight)_0_25%,transparent_0_50%)] bg-size-[16px_16px]">
|
||||
<ImageViewer data={bytes.buffer} mimeType={mime} className="object-contain" />
|
||||
</div>
|
||||
<div className="text-center text-xs text-text-subtle font-mono">
|
||||
{size != null && `${size.width} × ${size.height} • `}
|
||||
{mime} • {formatSize(bytes.length)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="h-full w-full grid place-items-center text-center text-text-subtlest text-sm px-6">
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
CountBadge,
|
||||
Dropdown,
|
||||
type DropdownItem,
|
||||
formatMillis,
|
||||
HStack,
|
||||
HttpStatusTagRaw,
|
||||
Icon,
|
||||
IconButton,
|
||||
SizeTag,
|
||||
type TabButtonRenderProps,
|
||||
type TabItem,
|
||||
TabContent,
|
||||
Tabs,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { selectedFlowAtom } from "../lib/atoms";
|
||||
import { findHeader, mimeEssence } from "../lib/body";
|
||||
import { saveDialogFlowAtom, useFlowActions } from "../lib/flowActions";
|
||||
import { statusReason } from "../lib/statusReason";
|
||||
import { EmptyState, FlowBodyViewer, useFlowBody } from "./FlowBodyViewer";
|
||||
import { FlowHeaders, FlowQuery, queryParams } from "./FlowHeaders";
|
||||
|
||||
const TAB_RESPONSE = "response";
|
||||
const TAB_REQUEST = "request";
|
||||
const TAB_HEADERS = "headers";
|
||||
const TAB_QUERY = "query";
|
||||
|
||||
export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideBySide: boolean }) {
|
||||
const flow = useAtomValue(selectedFlowAtom);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames("min-h-0 min-w-0 pb-2", sideBySide ? "pr-2 pt-2" : "px-2")}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
"x-theme-responsePane",
|
||||
"h-full bg-surface rounded-md border border-border-subtle overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{flow == null ? (
|
||||
<EmptyState>
|
||||
<div className="grid grid-cols-[auto_auto] gap-x-6 gap-y-2 text-left">
|
||||
<span>Select a flow</span>
|
||||
<Keys keys={["↑", "↓"]} />
|
||||
<span>Pause or resume recording</span>
|
||||
<Keys keys={["⌘", "R"]} />
|
||||
<span>Clear flows</span>
|
||||
<Keys keys={["⌘", "K"]} />
|
||||
</div>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<FlowPane flow={flow} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowPane({ flow }: { flow: Flow }) {
|
||||
const [pretty, setPretty] = useState(true);
|
||||
const responseBody = useFlowBody(flow, "response", flow.responseTruncated);
|
||||
// Failed or cancelled before any response; after the headers, the body was cut short mid-way.
|
||||
const noResponse = (flow.error != null || flow.cancelled) && flow.status == null;
|
||||
const mime = mimeEssence(findHeader(flow.responseHeaders, "content-type"));
|
||||
const queryCount = useMemo(() => queryParams(flow.url).length, [flow.url]);
|
||||
const openSave = useSetAtom(saveDialogFlowAtom);
|
||||
const flowActions = useFlowActions();
|
||||
|
||||
const tabs = useMemo<TabItem[]>(
|
||||
() => [
|
||||
{
|
||||
value: TAB_RESPONSE,
|
||||
renderButton: ({ buttonProps, isActive }) => (
|
||||
<ViewModeButton
|
||||
buttonProps={buttonProps}
|
||||
isActive={isActive}
|
||||
pretty={pretty}
|
||||
onChangePretty={setPretty}
|
||||
prettyLabel={mime === "text/html" ? "Preview" : "Response"}
|
||||
allowRaw={!mime?.startsWith("image/")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: TAB_REQUEST,
|
||||
label: "Request",
|
||||
rightSlot: flow.requestSize > 0 ? <CountBadge count={true} /> : null,
|
||||
},
|
||||
{
|
||||
value: TAB_HEADERS,
|
||||
label: "Headers",
|
||||
rightSlot: (
|
||||
<CountBadge
|
||||
count={flow.requestHeaders.length}
|
||||
count2={flow.responseHeaders.length}
|
||||
showZero
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: TAB_QUERY,
|
||||
label: "Query",
|
||||
rightSlot: <CountBadge count={queryCount} showZero />,
|
||||
},
|
||||
],
|
||||
[
|
||||
pretty,
|
||||
mime,
|
||||
flow.requestSize,
|
||||
flow.requestHeaders.length,
|
||||
flow.responseHeaders.length,
|
||||
queryCount,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
|
||||
<div
|
||||
className={classNames(
|
||||
"grid grid-cols-[auto_minmax(4rem,1fr)_auto] items-center h-sm -mb-1.5",
|
||||
"cursor-default select-none whitespace-nowrap w-full px-3",
|
||||
"overflow-x-auto font-mono text-sm text-text-subtle hide-scrollbars",
|
||||
)}
|
||||
>
|
||||
<HStack space={2} className="w-full shrink-0">
|
||||
{flow.cancelled && flow.status == null ? (
|
||||
<span>Cancelled</span>
|
||||
) : (
|
||||
<HttpStatusTagRaw
|
||||
showReason
|
||||
status={flow.status ?? 0}
|
||||
statusReason={flow.status == null ? null : statusReason(flow.status)}
|
||||
/>
|
||||
)}
|
||||
{flow.elapsedMs != null && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span
|
||||
title={
|
||||
flow.elapsedHeadersMs == null
|
||||
? undefined
|
||||
: `HEADERS: ${formatMillis(flow.elapsedHeadersMs)}\nTOTAL: ${formatMillis(flow.elapsedMs)}`
|
||||
}
|
||||
>
|
||||
{formatMillis(flow.elapsedMs)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{!noResponse && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<SizeTag contentLength={flow.responseSize} />
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
{flow.responseTruncated ? (
|
||||
<span
|
||||
className={classNames(
|
||||
"justify-self-end flex items-center gap-1.5 h-5 px-2.5 ml-3 rounded-full",
|
||||
"border border-notice/50 text-notice font-sans text-xs",
|
||||
)}
|
||||
>
|
||||
<Icon icon="scissors" size="xs" />
|
||||
{responseBody.data == null
|
||||
? "Body cut"
|
||||
: `First ${formatSize(responseBody.data.storedSize)} kept`}
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<HStack space={1} className="font-sans ml-3">
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color={flow.savedRequestId == null ? "default" : "primary"}
|
||||
title={flow.savedRequestId == null ? undefined : "Saved to Yaak. Click to save again."}
|
||||
leftSlot={<Icon icon="bookmark" size="xs" />}
|
||||
onClick={() => openSave(flow)}
|
||||
>
|
||||
{flow.savedRequestId == null ? "Save to Yaak" : "Saved"}
|
||||
</Button>
|
||||
<Dropdown items={flowActions(flow).slice(2)}>
|
||||
<IconButton size="xs" icon="ellipsis" iconColor="secondary" title="Copy and export" />
|
||||
</Dropdown>
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden flex flex-col min-h-0">
|
||||
{flow.error != null && (
|
||||
<Banner color="danger" className="mx-3 mt-1 shrink-0 font-mono text-sm">
|
||||
{flow.error}
|
||||
</Banner>
|
||||
)}
|
||||
{flow.cancelled && (
|
||||
<Banner color="secondary" className="mx-3 mt-1 shrink-0 text-sm">
|
||||
{flow.status == null
|
||||
? "The app cancelled the request before a response came back."
|
||||
: "The app cancelled the request before the response finished."}
|
||||
</Banner>
|
||||
)}
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
label="Flow"
|
||||
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
|
||||
tabListClassName="mt-0.5 -mb-1.5"
|
||||
>
|
||||
<TabContent value={TAB_RESPONSE}>
|
||||
{noResponse ? (
|
||||
<EmptyState>
|
||||
<div className="text-text-subtle mb-1">
|
||||
{flow.cancelled ? "Cancelled" : "No response"}
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
{flow.cancelled
|
||||
? "The app cancelled the request before a response came back."
|
||||
: "The request never got a response."}{" "}
|
||||
The Request tab has what the app sent.
|
||||
</div>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<FlowBodyViewer flow={flow} which="response" pretty={pretty} />
|
||||
)}
|
||||
</TabContent>
|
||||
<TabContent value={TAB_REQUEST}>
|
||||
<FlowBodyViewer flow={flow} which="request" pretty />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_HEADERS}>
|
||||
<FlowHeaders flow={flow} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_QUERY}>
|
||||
<FlowQuery flow={flow} />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ViewModeButtonProps {
|
||||
buttonProps: TabButtonRenderProps["buttonProps"];
|
||||
isActive: boolean;
|
||||
pretty: boolean;
|
||||
onChangePretty: (pretty: boolean) => void;
|
||||
prettyLabel: string;
|
||||
allowRaw: boolean;
|
||||
}
|
||||
|
||||
/** The client's Response tab: a click on the active tab opens its view menu */
|
||||
function ViewModeButton({
|
||||
buttonProps,
|
||||
isActive,
|
||||
pretty,
|
||||
onChangePretty,
|
||||
prettyLabel,
|
||||
allowRaw,
|
||||
}: ViewModeButtonProps) {
|
||||
const showPretty = pretty || !allowRaw;
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
label: prettyLabel,
|
||||
leftSlot: <CheckMark visible={showPretty} />,
|
||||
onSelect: () => onChangePretty(true),
|
||||
},
|
||||
{
|
||||
label: `${prettyLabel} (Raw)`,
|
||||
hidden: !allowRaw,
|
||||
leftSlot: <CheckMark visible={!showPretty} />,
|
||||
onSelect: () => onChangePretty(false),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown items={items}>
|
||||
<Button
|
||||
{...buttonProps}
|
||||
onClick={(e) => {
|
||||
if (isActive) return;
|
||||
e.preventDefault();
|
||||
buttonProps.onClick?.(e);
|
||||
}}
|
||||
rightSlot={
|
||||
<Icon
|
||||
size="sm"
|
||||
icon="chevron_down"
|
||||
className={classNames("ml-1", isActive ? "text-text-subtle" : "text-text-subtlest")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{showPretty ? prettyLabel : "Raw"}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckMark({ visible }: { visible: boolean }) {
|
||||
return <Icon icon={visible ? "check" : "empty"} size="sm" />;
|
||||
}
|
||||
|
||||
function Keys({ keys }: { keys: string[] }) {
|
||||
return (
|
||||
<span className="flex gap-1 justify-end">
|
||||
{keys.map((k) => (
|
||||
<kbd
|
||||
key={k}
|
||||
className="min-w-5 h-5 px-1 grid place-items-center rounded-sm border border-border-subtle font-sans text-xs text-text-subtle"
|
||||
>
|
||||
{k}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
DetailsBanner,
|
||||
formatMillis,
|
||||
HeadersTable,
|
||||
KeyValueRow,
|
||||
KeyValueRows,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { useMemo } from "react";
|
||||
import { EmptyState } from "./FlowBodyViewer";
|
||||
|
||||
const startedFormat = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
fractionalSecondDigits: 3,
|
||||
timeZoneName: "short",
|
||||
});
|
||||
|
||||
const bytesFormat = new Intl.NumberFormat("en-US");
|
||||
|
||||
export function FlowHeaders({ flow }: { flow: Flow }) {
|
||||
const versions =
|
||||
flow.upstreamHttpVersion == null
|
||||
? `${flow.clientHttpVersion} from the app`
|
||||
: `${flow.clientHttpVersion} from the app, ${flow.upstreamHttpVersion} to the server`;
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
||||
<DetailsBanner defaultOpen storageKey="flow.info" summary={<h2>Info</h2>}>
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Started">
|
||||
{startedFormat.format(new Date(`${flow.startedAt}Z`))}
|
||||
</KeyValueRow>
|
||||
<KeyValueRow label="URL">{flow.url}</KeyValueRow>
|
||||
<KeyValueRow label="Remote Address">{flow.remoteAddr ?? <Missing />}</KeyValueRow>
|
||||
<KeyValueRow label="Version">{versions}</KeyValueRow>
|
||||
<KeyValueRow label="Timing">
|
||||
{flow.elapsedMs == null ? (
|
||||
<Missing />
|
||||
) : flow.elapsedHeadersMs == null ? (
|
||||
`${formatMillis(flow.elapsedMs)} in all`
|
||||
) : (
|
||||
`${formatMillis(flow.elapsedMs)} in all, headers after ${formatMillis(flow.elapsedHeadersMs)}`
|
||||
)}
|
||||
</KeyValueRow>
|
||||
<KeyValueRow label="Size">
|
||||
<span>
|
||||
{formatSize(flow.requestSize)} sent, {formatSize(flow.responseSize)} received{" "}
|
||||
<span className="text-text-subtlest">
|
||||
({bytesFormat.format(flow.responseSize)} B)
|
||||
</span>
|
||||
</span>
|
||||
</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
</DetailsBanner>
|
||||
<HeadersTable
|
||||
title="Request Headers"
|
||||
headers={flow.requestHeaders}
|
||||
labelColor="primary"
|
||||
storageKey="flow.request_headers"
|
||||
/>
|
||||
<HeadersTable
|
||||
defaultOpen
|
||||
title="Response Headers"
|
||||
headers={flow.responseHeaders}
|
||||
labelColor="info"
|
||||
storageKey="flow.response_headers"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlowQuery({ flow }: { flow: Flow }) {
|
||||
const params = useMemo(() => queryParams(flow.url), [flow.url]);
|
||||
|
||||
if (params.length === 0) {
|
||||
return <EmptyState>No query parameters</EmptyState>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full pb-4">
|
||||
<KeyValueRows>
|
||||
{params.map(([name, value], i) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key -- a name can repeat
|
||||
<KeyValueRow key={i} label={name} labelColor="primary">
|
||||
{value}
|
||||
</KeyValueRow>
|
||||
))}
|
||||
</KeyValueRows>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function queryParams(url: string): [string, string][] {
|
||||
try {
|
||||
return [...new URL(url).searchParams.entries()];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function Missing() {
|
||||
return <span className="text-text-subtlest">--</span>;
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
ContextMenu,
|
||||
formatMillis,
|
||||
HttpMethodTagRaw,
|
||||
HttpStatusTagRaw,
|
||||
Icon,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import type { CSSProperties, KeyboardEvent, MouseEvent, WheelEvent } from "react";
|
||||
import { memo, useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { selectedFlowAtom, selectedFlowIdAtom } from "../lib/atoms";
|
||||
import { shortContentType } from "../lib/body";
|
||||
import { useFlowActions } from "../lib/flowActions";
|
||||
import { compareFlows } from "../lib/flowWindow";
|
||||
import { countNewFlows } from "../lib/follow";
|
||||
import {
|
||||
activeQueryAtom,
|
||||
followAtom,
|
||||
hasAppsAtom,
|
||||
loadOlderFlowsAtom,
|
||||
newestStartedAtAtom,
|
||||
oldestFlowAtom,
|
||||
searchWindowAtom,
|
||||
shownFlowsAtom,
|
||||
} from "../lib/store";
|
||||
import { AppIcon } from "./AppIcon";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
|
||||
const ROW_HEIGHT = 24;
|
||||
const BOTTOM_PADDING = 6;
|
||||
/** Rows from the top at which older flows start loading */
|
||||
const LOAD_OLDER_ROWS = 20;
|
||||
|
||||
interface Props {
|
||||
sideBySide: boolean;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows new traffic until a flow is selected or the list is scrolled, then holds. Scrolling to
|
||||
* the top loads older flows from the database.
|
||||
*/
|
||||
export function FlowList({ sideBySide, style, className }: Props) {
|
||||
const flows = useAtomValue(shownFlowsAtom);
|
||||
const hasApps = useAtomValue(hasAppsAtom);
|
||||
const [menu, setMenu] = useState<{ flow: Flow; x: number; y: number } | null>(null);
|
||||
const flowActions = useFlowActions();
|
||||
const selectedId = useAtomValue(selectedFlowIdAtom);
|
||||
const setSelectedFlow = useSetAtom(selectedFlowAtom);
|
||||
const loadOlder = useSetAtom(loadOlderFlowsAtom);
|
||||
const oldestFlow = useAtomValue(oldestFlowAtom);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const ignoreScroll = useRef(false);
|
||||
const [following, setFollowing] = useAtom(followAtom);
|
||||
const [heldAt, setHeldAt] = useState<string | null>(null);
|
||||
// The newest flow across all traffic, not just this list, so a filter change keeps the hold
|
||||
const newestStartedAt = useAtomValue(newestStartedAtAtom);
|
||||
// Read by the callbacks below, which stay the same across new traffic so rows don't re-render
|
||||
const latest = useRef({ flows, following, newestStartedAt });
|
||||
useLayoutEffect(() => {
|
||||
latest.current = { flows, following, newestStartedAt };
|
||||
});
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: flows.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
getItemKey: (index) => flows[index]?.id ?? index,
|
||||
paddingEnd: BOTTOM_PADDING,
|
||||
overscan: 40,
|
||||
});
|
||||
|
||||
const setScrollTop = useCallback((top: number) => {
|
||||
const el = scrollRef.current;
|
||||
if (el == null) return;
|
||||
const max = el.scrollHeight - el.clientHeight;
|
||||
const next = Math.max(0, Math.min(top, max));
|
||||
if (Math.abs(el.scrollTop - next) < 1) return;
|
||||
ignoreScroll.current = true;
|
||||
el.scrollTop = next;
|
||||
}, []);
|
||||
|
||||
const hold = useCallback(() => {
|
||||
if (!latest.current.following) return;
|
||||
setFollowing(false);
|
||||
setHeldAt(latest.current.newestStartedAt);
|
||||
}, [setFollowing]);
|
||||
|
||||
const follow = useCallback(() => {
|
||||
setFollowing(true);
|
||||
setHeldAt(null);
|
||||
setScrollTop(Number.MAX_SAFE_INTEGER);
|
||||
}, [setFollowing, setScrollTop]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (following) setScrollTop(Number.MAX_SAFE_INTEGER);
|
||||
}, [following, flows, setScrollTop]);
|
||||
|
||||
// Older flows that loaded in above push the rows down; scroll by as much, so they stay put.
|
||||
const anchor = useRef<Flow | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const previous = anchor.current;
|
||||
anchor.current = oldestFlow;
|
||||
if (following || previous == null || oldestFlow == null) return;
|
||||
if (compareFlows(oldestFlow, previous) >= 0) return;
|
||||
const added = flows.findIndex((f) => compareFlows(f, previous) >= 0);
|
||||
const el = scrollRef.current;
|
||||
if (added > 0 && el != null) setScrollTop(el.scrollTop + added * ROW_HEIGHT);
|
||||
}, [oldestFlow, flows, following, setScrollTop]);
|
||||
|
||||
const loadOlderHeld = useCallback(() => {
|
||||
hold();
|
||||
void loadOlder();
|
||||
}, [hold, loadOlder]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (ignoreScroll.current) {
|
||||
ignoreScroll.current = false;
|
||||
return;
|
||||
}
|
||||
const el = scrollRef.current;
|
||||
if (el == null) return;
|
||||
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 2;
|
||||
if (atEnd) {
|
||||
setFollowing(true);
|
||||
setHeldAt(null);
|
||||
} else {
|
||||
hold();
|
||||
if (el.scrollTop < LOAD_OLDER_ROWS * ROW_HEIGHT) void loadOlder();
|
||||
}
|
||||
}, [hold, loadOlder, setFollowing]);
|
||||
|
||||
// A list too short to scroll still loads older flows on scrolling up.
|
||||
const handleWheel = useCallback(
|
||||
(e: WheelEvent<HTMLDivElement>) => {
|
||||
if (e.deltaY < 0 && e.currentTarget.scrollTop <= 0) loadOlderHeld();
|
||||
},
|
||||
[loadOlderHeld],
|
||||
);
|
||||
|
||||
const select = useCallback(
|
||||
(index: number) => {
|
||||
const flow = latest.current.flows[index];
|
||||
if (flow == null) return;
|
||||
setSelectedFlow(flow);
|
||||
hold();
|
||||
const el = scrollRef.current;
|
||||
if (el == null) return;
|
||||
const top = index * ROW_HEIGHT;
|
||||
if (top < el.scrollTop) setScrollTop(top);
|
||||
else if (top + ROW_HEIGHT > el.scrollTop + el.clientHeight) {
|
||||
setScrollTop(top + ROW_HEIGHT - el.clientHeight);
|
||||
}
|
||||
},
|
||||
[hold, setSelectedFlow, setScrollTop],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||||
e.preventDefault();
|
||||
const index = flows.findIndex((f) => f.id === selectedId);
|
||||
if (index === -1) select(e.key === "ArrowDown" ? 0 : flows.length - 1);
|
||||
else if (index === 0 && e.key === "ArrowUp") loadOlderHeld();
|
||||
else select(e.key === "ArrowDown" ? index + 1 : index - 1);
|
||||
},
|
||||
[flows, loadOlderHeld, select, selectedId],
|
||||
);
|
||||
|
||||
const openMenu = useCallback(
|
||||
(index: number, e: MouseEvent) => {
|
||||
const flow = latest.current.flows[index];
|
||||
if (flow == null) return;
|
||||
e.preventDefault();
|
||||
select(index);
|
||||
setMenu({ flow, x: e.clientX, y: e.clientY });
|
||||
},
|
||||
[select],
|
||||
);
|
||||
|
||||
const newCount = following ? 0 : countNewFlows(flows, heldAt);
|
||||
|
||||
const columns = sideBySide
|
||||
? hasApps
|
||||
? "grid-cols-[0.9rem_1rem_2.6rem_minmax(0,2fr)_minmax(0,3fr)_2.4rem_3.6rem]"
|
||||
: "grid-cols-[0.9rem_2.6rem_minmax(0,2fr)_minmax(0,3fr)_2.4rem_3.6rem]"
|
||||
: hasApps
|
||||
? "grid-cols-[0.9rem_8.5rem_2.6rem_13rem_minmax(0,1fr)_2.4rem_5rem_4.5rem_3.8rem]"
|
||||
: "grid-cols-[0.9rem_2.6rem_13rem_minmax(0,1fr)_2.4rem_5rem_4.5rem_3.8rem]";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames(className, "grid grid-rows-[auto_auto_minmax(0,1fr)] min-h-0 text-sm")}
|
||||
>
|
||||
<SearchBar />
|
||||
<div
|
||||
className={classNames(
|
||||
columns,
|
||||
"grid items-center gap-x-3 h-[26px] px-1.5 mx-1.5",
|
||||
"text-text-subtlest border-b border-surface-highlight",
|
||||
)}
|
||||
>
|
||||
<span />
|
||||
{hasApps && <span>{sideBySide ? "" : "App"}</span>}
|
||||
<span>Method</span>
|
||||
<span>Host</span>
|
||||
<span>Path</span>
|
||||
<span>Status</span>
|
||||
{!sideBySide && <span>Type</span>}
|
||||
{!sideBySide && <span className="text-right">Size</span>}
|
||||
<span className="text-right">Time</span>
|
||||
</div>
|
||||
<div className="relative min-h-0">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
role="listbox"
|
||||
aria-label="Flows"
|
||||
tabIndex={0}
|
||||
onScroll={handleScroll}
|
||||
onWheel={handleWheel}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="group/flows h-full overflow-y-auto px-1.5 outline-hidden"
|
||||
>
|
||||
{flows.length === 0 ? (
|
||||
<EmptyList />
|
||||
) : (
|
||||
<div className="relative" style={{ height: virtualizer.getTotalSize() }}>
|
||||
{virtualizer.getVirtualItems().map((item) => {
|
||||
const flow = flows[item.index];
|
||||
if (flow == null) return null;
|
||||
return (
|
||||
<FlowRow
|
||||
key={flow.id}
|
||||
flow={flow}
|
||||
index={item.index}
|
||||
start={item.start}
|
||||
columns={columns}
|
||||
sideBySide={sideBySide}
|
||||
showApp={hasApps}
|
||||
selected={flow.id === selectedId}
|
||||
onSelect={select}
|
||||
onContextMenu={openMenu}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{newCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={follow}
|
||||
className={classNames(
|
||||
"absolute bottom-3 left-1/2 -translate-x-1/2",
|
||||
"flex items-center gap-1.5 h-6 px-3 rounded-full",
|
||||
"bg-surface border border-border shadow-lg text-text text-xs",
|
||||
"hover:bg-surface-highlight",
|
||||
)}
|
||||
>
|
||||
<Icon icon="arrow_down_to_line" size="sm" color="primary" />
|
||||
{newCount === 1 ? "1 new flow" : `${newCount} new flows`}
|
||||
</button>
|
||||
)}
|
||||
<ContextMenu
|
||||
triggerPosition={menu}
|
||||
items={menu == null ? [] : flowActions(menu.flow)}
|
||||
onClose={() => setMenu(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Nothing to list, or nothing a search matches */
|
||||
function EmptyList() {
|
||||
const query = useAtomValue(activeQueryAtom);
|
||||
const search = useAtomValue(searchWindowAtom);
|
||||
if (search?.loading) return null;
|
||||
return (
|
||||
<p className="text-text-subtlest text-sm px-3 py-3">
|
||||
{query == null ? "No traffic yet" : "No flows match"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
flow: Flow;
|
||||
index: number;
|
||||
start: number;
|
||||
columns: string;
|
||||
sideBySide: boolean;
|
||||
showApp: boolean;
|
||||
selected: boolean;
|
||||
onSelect: (index: number) => void;
|
||||
onContextMenu: (index: number, e: MouseEvent) => void;
|
||||
}
|
||||
|
||||
const FlowRow = memo(function FlowRow({
|
||||
flow,
|
||||
index,
|
||||
start,
|
||||
columns,
|
||||
sideBySide,
|
||||
showApp,
|
||||
selected,
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
}: RowProps) {
|
||||
const { host, path, query } = splitUrl(flow.url);
|
||||
const failed = flow.error != null;
|
||||
// Failed or cancelled before any response; after the headers, the body was cut short mid-way.
|
||||
const noResponse = (failed || flow.cancelled) && flow.status == null;
|
||||
const type = shortContentType(flow.contentType);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 0) onSelect(index);
|
||||
}}
|
||||
onContextMenu={(e) => onContextMenu(index, e)}
|
||||
style={{ height: ROW_HEIGHT, transform: `translateY(${start}px)` }}
|
||||
className={classNames(
|
||||
columns,
|
||||
"absolute top-0 inset-x-0 grid items-center gap-x-3 px-1.5 rounded-md whitespace-nowrap",
|
||||
"[&>*]:overflow-hidden [&>*]:text-ellipsis [&>*]:min-w-0",
|
||||
selected
|
||||
? "text-text bg-surface-highlight group-focus-within/flows:bg-surface-active"
|
||||
: index % 2 === 1 && "bg-surface-highlight/30",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center justify-center">
|
||||
{flow.savedRequestId != null ? (
|
||||
<Icon icon="bookmark" size="xs" color="primary" title="Saved to Yaak" />
|
||||
) : failed ? (
|
||||
<Icon icon="circle_alert" size="xs" color="danger" />
|
||||
) : flow.cancelled ? (
|
||||
<Icon icon="circle_off" size="xs" color="secondary" title="Cancelled by the app" />
|
||||
) : flow.responseTruncated ? (
|
||||
<Icon icon="scissors" size="xs" color="notice" title="Body cut at the capture limit" />
|
||||
) : null}
|
||||
</span>
|
||||
{showApp && (
|
||||
<span className="flex items-center gap-1.5" title={flow.clientPath ?? undefined}>
|
||||
{flow.clientPath != null && <AppIcon path={flow.clientPath} />}
|
||||
{!sideBySide && (
|
||||
<span className="truncate text-text-subtle">
|
||||
{flow.clientName ?? <span className="text-text-subtlest">--</span>}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<HttpMethodTagRaw method={flow.method} short colored />
|
||||
<span className="text-text-subtle">{host}</span>
|
||||
<span>
|
||||
{path}
|
||||
<span className="text-text-subtlest">{query}</span>
|
||||
</span>
|
||||
{flow.cancelled && flow.status == null ? (
|
||||
<span className="font-mono text-text-subtlest">--</span>
|
||||
) : (
|
||||
<HttpStatusTagRaw status={flow.status ?? 0} short />
|
||||
)}
|
||||
{!sideBySide &&
|
||||
(failed ? (
|
||||
<span className="text-danger" title={flow.error ?? undefined}>
|
||||
{flow.error}
|
||||
</span>
|
||||
) : flow.cancelled ? (
|
||||
<span className="text-text-subtle">Cancelled</span>
|
||||
) : (
|
||||
<span className="text-text-subtle">
|
||||
{type ?? <span className="text-text-subtlest">--</span>}
|
||||
</span>
|
||||
))}
|
||||
{!sideBySide && (
|
||||
<span
|
||||
className={classNames(
|
||||
"font-mono text-right",
|
||||
flow.responseTruncated ? "text-notice" : "text-text-subtle",
|
||||
)}
|
||||
>
|
||||
{noResponse ? (
|
||||
<span className="text-text-subtlest">--</span>
|
||||
) : (
|
||||
formatSize(flow.responseSize)
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono text-right text-text-subtle">
|
||||
{flow.elapsedMs == null ? "--" : formatMillis(flow.elapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function splitUrl(url: string): { host: string; path: string; query: string } {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return { host: u.host, path: u.pathname, query: u.search };
|
||||
} catch {
|
||||
return { host: "", path: url, query: "" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { SystemProxyStatus } from "@yaakapp-internal/proxy-lib";
|
||||
import { Banner, Dialog, DialogFooter, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import loginItems from "../assets/login-items.png";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||
import { helperDialogOpenAtom, helperRegisteredAtAtom } from "../lib/atoms";
|
||||
|
||||
/** Yaak Proxy's row in `login-items.png`, as percentages of the image, for the highlight */
|
||||
const YAAK_PROXY_ROW = { left: 1, top: 34, width: 98, height: 32 };
|
||||
|
||||
/**
|
||||
* Walks through allowing the helper: registering it opens System Settings, where the user turns
|
||||
* it on. Stays open while macOS awaits that approval, and closes itself once it is given.
|
||||
*/
|
||||
export function HelperDialog() {
|
||||
const [open, setOpen] = useAtom(helperDialogOpenAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const setRegisteredAt = useSetAtom(helperRegisteredAtAtom);
|
||||
// Polled by the header while approval is pending, so this sees it as soon as it is given.
|
||||
const status = useRpcQuery("system_proxy_status", {});
|
||||
const register = useRpcMutation("helper_register", {
|
||||
onSuccess: (status) => {
|
||||
setRegisteredAt(Date.now());
|
||||
queryClient.setQueryData<SystemProxyStatus>(["system_proxy_status", {}], status);
|
||||
},
|
||||
});
|
||||
const helper = status.data?.helper;
|
||||
const waiting = helper === "requires_approval";
|
||||
const { reset } = register;
|
||||
|
||||
useEffect(() => {
|
||||
if (open && helper === "enabled") {
|
||||
setOpen(false);
|
||||
reset();
|
||||
}
|
||||
}, [open, helper, setOpen, reset]);
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={close}
|
||||
size="dynamic"
|
||||
title="Turn on Yaak Proxy under Allow in the Background"
|
||||
description="In System Settings > General > Login Items. Newer macOS calls the list Background App Activity."
|
||||
className="w-[34rem]"
|
||||
>
|
||||
<div className="grid gap-3 pb-4">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={loginItems}
|
||||
alt="System Settings, with Yaak Proxy's toggle in the list of apps allowed in the background"
|
||||
className="block w-full rounded-md border border-border-subtle"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute rounded-md ring-2 ring-primary bg-primary/10 pointer-events-none"
|
||||
style={{
|
||||
left: `${YAAK_PROXY_ROW.left}%`,
|
||||
top: `${YAAK_PROXY_ROW.top}%`,
|
||||
width: `${YAAK_PROXY_ROW.width}%`,
|
||||
height: `${YAAK_PROXY_ROW.height}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{register.error != null && <Banner color="danger">{register.error.message}</Banner>}
|
||||
</div>
|
||||
<DialogFooter
|
||||
leftSlot={
|
||||
waiting && (
|
||||
<span className="flex items-center gap-2 text-sm text-text-subtle">
|
||||
<LoadingIcon size="xs" />
|
||||
Waiting for approval
|
||||
</span>
|
||||
)
|
||||
}
|
||||
actions={[
|
||||
{ label: waiting ? "Close" : "Cancel", onClick: close },
|
||||
{
|
||||
label: "Open System Settings",
|
||||
color: "primary",
|
||||
isLoading: register.isPending,
|
||||
onClick: () => register.mutate({}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,32 @@
|
||||
import { HeaderSize, IconButton, SidebarLayout, SplitLayout } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
HeaderSize,
|
||||
IconButton,
|
||||
SidebarLayout,
|
||||
SplitLayout,
|
||||
type SplitLayoutLayout,
|
||||
Toasts,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { useRpcQueryWithEvent } from "../hooks/useRpcQueryWithEvent";
|
||||
import { flowLayoutAtom } from "../lib/atoms";
|
||||
import { searchWindowAtom, showFirstRunAtom, shownTotalAtom } from "../lib/store";
|
||||
import { getOsType } from "../lib/tauri";
|
||||
import { ActionIconButton } from "./ActionIconButton";
|
||||
import { ExchangesTable } from "./ExchangesTable";
|
||||
import { filteredExchangesAtom, Sidebar } from "./Sidebar";
|
||||
import { ClearFlowsButton } from "./ClearFlowsButton";
|
||||
import { FirstRun } from "./FirstRun";
|
||||
import { FlowDetail } from "./FlowDetail";
|
||||
import { FlowList } from "./FlowList";
|
||||
import { ProxyStatus } from "./ProxyStatus";
|
||||
import { HelperDialog } from "./HelperDialog";
|
||||
import { SaveDialog } from "./SaveDialog";
|
||||
import { selectedSidebarLabelAtom, Sidebar } from "./Sidebar";
|
||||
import { TakeOverBanner } from "./TakeOverBanner";
|
||||
|
||||
export function ProxyLayout() {
|
||||
const os = getOsType();
|
||||
const exchanges = useAtomValue(filteredExchangesAtom);
|
||||
const title = useAtomValue(selectedSidebarLabelAtom);
|
||||
const [flowLayout, setFlowLayout] = useAtom(flowLayoutAtom);
|
||||
const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar_width", 250);
|
||||
const [sidebarHidden, setSidebarHidden] = useLocalStorage("sidebar_hidden", false);
|
||||
const [floatingSidebarHidden, setFloatingSidebarHidden] = useLocalStorage(
|
||||
@@ -19,14 +34,12 @@ export function ProxyLayout() {
|
||||
true,
|
||||
);
|
||||
const [floating, setFloating] = useState(false);
|
||||
const { data: proxyState } = useRpcQueryWithEvent("get_proxy_state", {}, "proxy_state_changed");
|
||||
const isRunning = proxyState?.state === "running";
|
||||
const isHidden = floating ? (floatingSidebarHidden ?? true) : (sidebarHidden ?? false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"h-full w-full grid grid-rows-[auto_1fr]",
|
||||
"h-full w-full grid grid-rows-[auto_minmax(0,1fr)]",
|
||||
os === "linux" && "border border-border-subtle",
|
||||
)}
|
||||
>
|
||||
@@ -57,29 +70,24 @@ export function ProxyLayout() {
|
||||
</div>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="pointer-events-none flex items-center text-sm px-2"
|
||||
className="pointer-events-none flex items-center justify-center gap-2 text-sm px-2 min-w-0"
|
||||
>
|
||||
Yaak Proxy
|
||||
<span className="truncate">{title}</span>
|
||||
<FlowCount />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pr-1 pointer-events-auto">
|
||||
{isRunning ? (
|
||||
<>
|
||||
<span className="text-2xs text-success">Running :9090</span>
|
||||
<ActionIconButton
|
||||
action={{ scope: "global", action: "proxy_stop" }}
|
||||
icon="circle_stop"
|
||||
iconColor="secondary"
|
||||
size="sm"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ActionIconButton
|
||||
action={{ scope: "global", action: "proxy_start" }}
|
||||
icon="circle_play"
|
||||
iconColor="secondary"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-1 pr-1">
|
||||
<ProxyStatus />
|
||||
<ClearFlowsButton />
|
||||
<IconButton
|
||||
className="pointer-events-auto"
|
||||
icon={flowLayout === "horizontal" ? "columns_2" : "rows_2"}
|
||||
title={`Change to ${flowLayout === "horizontal" ? "vertical" : "horizontal"} layout`}
|
||||
size="sm"
|
||||
iconColor="secondary"
|
||||
onClick={() =>
|
||||
setFlowLayout((prev) => (prev === "horizontal" ? "vertical" : "horizontal"))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</HeaderSize>
|
||||
@@ -124,23 +132,46 @@ export function ProxyLayout() {
|
||||
)
|
||||
}
|
||||
>
|
||||
<SplitLayout
|
||||
storageKey="proxy_detail"
|
||||
layout="vertical"
|
||||
defaultRatio={0.4}
|
||||
firstSlot={({ style }) => (
|
||||
<ExchangesTable exchanges={exchanges} style={style} className="overflow-auto" />
|
||||
)}
|
||||
secondSlot={({ style }) => (
|
||||
<div
|
||||
style={style}
|
||||
className="p-3 text-text-subtlest text-sm border-t border-border-subtle"
|
||||
>
|
||||
Select a request to view details
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className="h-full flex flex-col min-h-0">
|
||||
<TakeOverBanner />
|
||||
<div className="flex-1 min-h-0 grid grid-rows-[minmax(0,1fr)]">
|
||||
<FlowsOrFirstRun flowLayout={flowLayout} />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
<SaveDialog />
|
||||
<HelperDialog />
|
||||
<Toasts />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Its own component, so new traffic re-renders the count rather than the whole layout */
|
||||
function FlowCount() {
|
||||
const count = useAtomValue(shownTotalAtom);
|
||||
const searching = useAtomValue(searchWindowAtom) != null;
|
||||
const noun = searching ? (count === 1 ? "match" : "matches") : count === 1 ? "flow" : "flows";
|
||||
return <span className="text-text-subtlest shrink-0">{`${count} ${noun}`}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The flow list above the detail pane, or, while the database has no flows at all, the first-run
|
||||
* card across the whole area: it is the empty state until traffic replaces it.
|
||||
*/
|
||||
function FlowsOrFirstRun({ flowLayout }: { flowLayout: SplitLayoutLayout }) {
|
||||
if (useAtomValue(showFirstRunAtom)) return <FirstRun />;
|
||||
return (
|
||||
<SplitLayout
|
||||
storageKey="proxy_detail"
|
||||
className="gap-1"
|
||||
layout={flowLayout}
|
||||
defaultRatio={0.55}
|
||||
firstSlot={({ style, orientation }) => (
|
||||
<FlowList sideBySide={orientation === "horizontal"} style={style} className="pt-1" />
|
||||
)}
|
||||
secondSlot={({ style, orientation }) => (
|
||||
<FlowDetail style={style} sideBySide={orientation === "horizontal"} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { CaStatus, SystemProxyStatus } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
type DropdownItem,
|
||||
type DropdownRef,
|
||||
Icon,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue, useSetAtom, useStore } from "jotai";
|
||||
import type { HTMLAttributes, ReactNode, Ref } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRpcEvent } from "../hooks/useRpcEvent";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||
import { helperDialogOpenAtom, helperRegisteredAtAtom } from "../lib/atoms";
|
||||
import { environmentExports } from "../lib/env";
|
||||
import { proxyStateAtom, refreshProxyState } from "../lib/store";
|
||||
import {
|
||||
helperPollInterval,
|
||||
helperRow,
|
||||
recordingLabel,
|
||||
systemProxySwitch,
|
||||
} from "../lib/systemProxy";
|
||||
import { onWindowFocus } from "../lib/tauri";
|
||||
import { useShowToast } from "../lib/toast";
|
||||
|
||||
/**
|
||||
* The header's state pill and its menu: recording, the system proxy, the certificate and HTTP/2. The
|
||||
* engine runs for as long as the app does, so the pill shows whether flows are being stored. A
|
||||
* certificate that is not trusted steps out of the menu beside the pill until it is.
|
||||
*/
|
||||
export function ProxyStatus() {
|
||||
const dropdownRef = useRef<DropdownRef>(null);
|
||||
const proxyState = useAtomValue(proxyStateAtom);
|
||||
const setup = useProxySetup();
|
||||
const execute = useRpcMutation("execute_action");
|
||||
const retry = useRpcMutation("take_over");
|
||||
const setAtLaunch = useSetSystemProxyAtLaunch();
|
||||
const setHttpVersion = useSetHttpVersion();
|
||||
const copyEnvironment = useCopyEnvironment();
|
||||
const setHelperDialogOpen = useSetAtom(helperDialogOpenAtom);
|
||||
const { caStatus, trust, systemProxy, setSystemProxy } = setup;
|
||||
const systemSwitch = useSystemProxySwitch(setup);
|
||||
|
||||
const running = proxyState?.state === "running";
|
||||
const recording = proxyState?.recording !== false;
|
||||
const other = proxyState?.otherOwner ?? null;
|
||||
const port = proxyState?.port ?? other?.proxyPort ?? null;
|
||||
const untrusted = caStatus.data != null && !caStatus.data.trusted;
|
||||
|
||||
const stateRow =
|
||||
other != null ? (
|
||||
<MenuRow
|
||||
icon={<StateDot state="cli" />}
|
||||
title="Running from the CLI"
|
||||
description={`Started by yaak proxy start, pid ${other.pid}, since ${timeOf(other.startedAt)}.${other.proxyPort == null ? "" : ` Port ${other.proxyPort}.`}`}
|
||||
/>
|
||||
) : running ? (
|
||||
<MenuRow
|
||||
icon={<StateDot state={recording ? "recording" : "paused"} />}
|
||||
title={recording ? `Recording on port ${port}` : `Paused on port ${port}`}
|
||||
description={
|
||||
recording
|
||||
? "Listening on 127.0.0.1, and storing every flow."
|
||||
: "Traffic passes through untouched, and no flows are stored."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MenuRow
|
||||
icon={<Icon icon="alert_triangle" size="sm" color="warning" />}
|
||||
title="Not running"
|
||||
description={
|
||||
<span className="text-danger">
|
||||
{retry.error?.message ?? proxyState?.startError ?? "The proxy has not started."}
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
isLoading={retry.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
retry.mutate({});
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const items: DropdownItem[] = [
|
||||
{ type: "content", label: stateRow },
|
||||
{ type: "separator", hidden: systemProxy.data?.supported !== true },
|
||||
{
|
||||
type: "content",
|
||||
hidden: systemProxy.data?.supported !== true,
|
||||
label: (
|
||||
<MenuRow
|
||||
icon={<Icon icon="wifi" size="sm" color="secondary" />}
|
||||
title="Override system proxy"
|
||||
description={
|
||||
<span className={classNames(systemSwitch.failed && "text-danger")}>
|
||||
{systemSwitch.description}
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
<KeepMenuOpen>
|
||||
<Checkbox
|
||||
title="Override system proxy"
|
||||
hideLabel
|
||||
checked={systemSwitch.checked}
|
||||
// Only the process that owns the engine routes to it.
|
||||
disabled={!running || other != null || setSystemProxy.isPending}
|
||||
onChange={(enabled) => setSystemProxy.mutate({ enabled })}
|
||||
/>
|
||||
</KeepMenuOpen>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "content",
|
||||
hidden: systemProxy.data?.supported !== true,
|
||||
label: (
|
||||
<KeepMenuOpen className="block pl-[2.125rem] pr-1.5 pb-1.5">
|
||||
<Checkbox
|
||||
title={
|
||||
<span className="text-xs text-text-subtle">Override system proxy at launch</span>
|
||||
}
|
||||
checked={proxyState?.systemProxyAtLaunch ?? true}
|
||||
disabled={setAtLaunch.isPending}
|
||||
onChange={(enabled) => setAtLaunch.mutate({ enabled })}
|
||||
/>
|
||||
</KeepMenuOpen>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "content",
|
||||
hidden: systemProxy.data?.supported !== true,
|
||||
label: systemProxy.data != null && (
|
||||
<HelperMenuRow
|
||||
status={systemProxy.data}
|
||||
onOpenDialog={() => {
|
||||
dropdownRef.current?.close?.();
|
||||
setHelperDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "content",
|
||||
label: caStatus.data?.trusted ? (
|
||||
<MenuRow
|
||||
icon={<Icon icon="shield_check" color="success" size="sm" />}
|
||||
title="Certificate trusted"
|
||||
description="Yaak Proxy CA, in your login keychain."
|
||||
/>
|
||||
) : (
|
||||
<MenuRow
|
||||
icon={<Icon icon="shield_off" color="warning" size="sm" />}
|
||||
title="Certificate not trusted"
|
||||
description={
|
||||
trust.error != null ? (
|
||||
<span className="text-danger">{trust.error.message}</span>
|
||||
) : trust.isPending ? (
|
||||
"Waiting for your password in the macOS prompt."
|
||||
) : (
|
||||
"So HTTPS reads as plain text. macOS asks for your login password once."
|
||||
)
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color="primary"
|
||||
isLoading={trust.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
trust.mutate({});
|
||||
}}
|
||||
>
|
||||
{trust.error != null ? "Try again" : "Trust certificate"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "content",
|
||||
label: (
|
||||
<MenuRow
|
||||
icon={<Icon icon="zap" size="sm" color="secondary" />}
|
||||
title="HTTP/2"
|
||||
description="Lets browsers send many requests at once. Turn off for apps that misbehave on it. Applies to new connections."
|
||||
action={
|
||||
<KeepMenuOpen>
|
||||
<Checkbox
|
||||
title="HTTP/2"
|
||||
hideLabel
|
||||
checked={proxyState?.httpVersion !== "http1Only"}
|
||||
// The owner's engine would not see the change until it restarts.
|
||||
disabled={other != null || setHttpVersion.isPending}
|
||||
onChange={(enabled) =>
|
||||
setHttpVersion.mutate({ httpVersion: enabled ? "auto" : "http1Only" })
|
||||
}
|
||||
/>
|
||||
</KeepMenuOpen>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Copy Environment for a Terminal",
|
||||
icon: "square_terminal",
|
||||
onSelect: copyEnvironment,
|
||||
},
|
||||
{
|
||||
label: recordingLabel(recording),
|
||||
icon: recording ? "circle_pause" : "circle_play",
|
||||
hidden: !running,
|
||||
hotKeyAction: "recording_toggle",
|
||||
hotKeyLabelOnly: true,
|
||||
onSelect: () => execute.mutate({ scope: "global", action: "recording_toggle" }),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{untrusted && (
|
||||
<Pill color="warning" onClick={() => dropdownRef.current?.open()}>
|
||||
<Icon icon="shield_off" size="xs" />
|
||||
Certificate not trusted
|
||||
</Pill>
|
||||
)}
|
||||
<Dropdown ref={dropdownRef} items={items}>
|
||||
<Pill color={!running && other == null && proxyState != null ? "warning" : undefined}>
|
||||
{other != null ? (
|
||||
<>
|
||||
<StateDot state="cli" />
|
||||
<span className="text-text">CLI</span>
|
||||
{other.proxyPort != null && (
|
||||
<span className="font-mono text-text-subtle">:{other.proxyPort}</span>
|
||||
)}
|
||||
</>
|
||||
) : running ? (
|
||||
<>
|
||||
<StateDot state={recording ? "recording" : "paused"} />
|
||||
<span className="text-text">{recording ? "Recording" : "Paused"}</span>
|
||||
{port != null && <span className="font-mono text-text-subtle">:{port}</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="alert_triangle" size="xs" />
|
||||
Not running
|
||||
</>
|
||||
)}
|
||||
<Icon icon="chevron_down" size="xs" className="text-text-subtlest" />
|
||||
</Pill>
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The certificate and the system proxy, read once and shared by the header and the first-run
|
||||
* card, with the actions that change them.
|
||||
*/
|
||||
export function useProxySetup() {
|
||||
const queryClient = useQueryClient();
|
||||
const caStatus = useRpcQuery("ca_status", {});
|
||||
const registeredAt = useAtomValue(helperRegisteredAtAtom);
|
||||
const systemProxy = useRpcQuery(
|
||||
"system_proxy_status",
|
||||
{},
|
||||
{
|
||||
// The helper is approved in System Settings, out of the app's sight.
|
||||
refetchInterval: (query) =>
|
||||
helperPollInterval(query.state.data?.helper, registeredAt, Date.now()),
|
||||
},
|
||||
);
|
||||
// The user comes back from System Settings, maybe having just allowed the helper.
|
||||
const { refetch: refetchSystemProxy } = systemProxy;
|
||||
useEffect(() => onWindowFocus(() => void refetchSystemProxy()), [refetchSystemProxy]);
|
||||
const trust = useRpcMutation("ca_trust", {
|
||||
mutationKey: ["ca_trust"],
|
||||
onSuccess: (status) => queryClient.setQueryData<CaStatus>(["ca_status", {}], status),
|
||||
});
|
||||
const setSystemProxy = useRpcMutation("system_proxy_set", {
|
||||
mutationKey: ["system_proxy_set"],
|
||||
onSuccess: (status) => {
|
||||
queryClient.setQueryData<SystemProxyStatus>(["system_proxy_status", {}], status);
|
||||
},
|
||||
});
|
||||
// The engine turns the system proxy on as it starts, and says so once the prompt is
|
||||
// answered, and turns it off as it stops.
|
||||
useRpcEvent("proxy_state_changed", () => void systemProxy.refetch());
|
||||
return { caStatus, trust, systemProxy, setSystemProxy };
|
||||
}
|
||||
|
||||
/** The system proxy switch's state, from where it points and the change in flight */
|
||||
export function useSystemProxySwitch(setup: ReturnType<typeof useProxySetup>) {
|
||||
const proxyState = useAtomValue(proxyStateAtom);
|
||||
const { systemProxy, setSystemProxy } = setup;
|
||||
return systemProxySwitch({
|
||||
routing: systemProxy.data?.enabled === true,
|
||||
port: proxyState?.port ?? proxyState?.configuredPort ?? null,
|
||||
pending: setSystemProxy.isPending ? (setSystemProxy.variables?.enabled ?? null) : null,
|
||||
error: setSystemProxy.error?.message ?? null,
|
||||
helper: systemProxy.data?.backend === "helper",
|
||||
});
|
||||
}
|
||||
|
||||
/** The privileged helper: whether it is installed and approved, and which way changes go */
|
||||
function HelperMenuRow({
|
||||
status,
|
||||
onOpenDialog,
|
||||
}: {
|
||||
status: SystemProxyStatus;
|
||||
onOpenDialog: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const unregister = useRpcMutation("helper_unregister", {
|
||||
onSuccess: (status) =>
|
||||
queryClient.setQueryData<SystemProxyStatus>(["system_proxy_status", {}], status),
|
||||
});
|
||||
const row = helperRow(status.helper, status.backend);
|
||||
const error = unregister.error;
|
||||
return (
|
||||
<MenuRow
|
||||
icon={
|
||||
<Icon
|
||||
icon={status.helper === "enabled" ? "badge_check" : "lock"}
|
||||
size="sm"
|
||||
color={status.helper === "enabled" ? "success" : "secondary"}
|
||||
/>
|
||||
}
|
||||
title={row.title}
|
||||
description={
|
||||
error != null ? <span className="text-danger">{error.message}</span> : row.description
|
||||
}
|
||||
action={
|
||||
row.action != null && (
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color={row.action === "unregister" ? "default" : "primary"}
|
||||
isLoading={unregister.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (row.action === "unregister") unregister.mutate({});
|
||||
else onOpenDialog();
|
||||
}}
|
||||
>
|
||||
{row.action === "install"
|
||||
? "Install"
|
||||
: row.action === "approve"
|
||||
? "Approve"
|
||||
: "Unregister"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Saves whether the system proxy turns on as the app launches */
|
||||
function useSetSystemProxyAtLaunch() {
|
||||
const store = useStore();
|
||||
return useRpcMutation("set_system_proxy_at_launch", {
|
||||
onSettled: () => void refreshProxyState(store),
|
||||
});
|
||||
}
|
||||
|
||||
/** Saves the HTTP versions offered to clients, which the engine applies to new connections */
|
||||
function useSetHttpVersion() {
|
||||
const store = useStore();
|
||||
return useRpcMutation("set_http_version", {
|
||||
onSettled: () => void refreshProxyState(store),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCopyEnvironment() {
|
||||
const proxyState = useAtomValue(proxyStateAtom);
|
||||
const caStatus = useRpcQuery("ca_status", {});
|
||||
const showToast = useShowToast();
|
||||
return async () => {
|
||||
const port =
|
||||
proxyState?.port ?? proxyState?.otherOwner?.proxyPort ?? proxyState?.configuredPort;
|
||||
const caPath = caStatus.data?.certPath;
|
||||
if (port == null || caPath == null) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(environmentExports(port, caPath));
|
||||
showToast({
|
||||
message: "Copied the environment. Paste it into a terminal to proxy what runs there.",
|
||||
});
|
||||
} catch (err) {
|
||||
showToast({ message: `Failed to copy: ${String(err)}`, color: "danger" });
|
||||
}
|
||||
};
|
||||
}
|
||||
/** Keeps a click on a control inside a menu row from closing the menu, as a row's click does */
|
||||
function KeepMenuOpen({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- only stops the click reaching the row
|
||||
<span className={className} onClick={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function timeOf(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
|
||||
interface PillProps extends HTMLAttributes<HTMLButtonElement> {
|
||||
color?: "warning";
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
function Pill({ children, color, ref, ...props }: PillProps) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
ref={ref}
|
||||
type="button"
|
||||
className={classNames(
|
||||
"pointer-events-auto flex items-center gap-1.5 h-[1.45rem] px-2.5 rounded-full border text-xs",
|
||||
"whitespace-nowrap outline-hidden focus-visible:ring-1 ring-border-focus",
|
||||
color === "warning"
|
||||
? "border-warning/50 text-warning hover:bg-warning/10"
|
||||
: "border-border-subtle text-text-subtle hover:bg-surface-highlight",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function StateDot({ state }: { state: "recording" | "paused" | "cli" }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
"inline-block w-[7px] h-[7px] rounded-full shrink-0",
|
||||
state === "recording" && "bg-success ring-3 ring-success/20",
|
||||
state === "cli" && "bg-info ring-3 ring-info/20",
|
||||
state === "paused" && "border-[1.5px] border-text-subtlest",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuRow({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[1rem_minmax(0,1fr)_auto] gap-x-3 items-start px-1.5 py-1 w-[24rem] max-w-full">
|
||||
<span className="h-5 flex items-center justify-center">{icon}</span>
|
||||
<div>
|
||||
<div className="text-sm text-text">{title}</div>
|
||||
<div className="text-xs text-text-subtle mt-0.5">{description}</div>
|
||||
</div>
|
||||
<span className="self-center">{action}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import type {
|
||||
DropReason,
|
||||
Flow,
|
||||
FolderSummary,
|
||||
WorkspaceSummary,
|
||||
} from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
Banner,
|
||||
Dialog,
|
||||
DialogFooter,
|
||||
HttpMethodTagRaw,
|
||||
LoadingIcon,
|
||||
PlainInput,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { unnamedRequestName } from "@yaakapp-internal/ui/resolvedModelName";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||
import { exportHar, saveDialogFlowAtom } from "../lib/flowActions";
|
||||
import { useShowToast } from "../lib/toast";
|
||||
|
||||
const DOWNLOAD_URL = "https://yaak.app/download";
|
||||
const SAVE_FORM_ID = "save-flow-to-yaak";
|
||||
|
||||
/** Save as Yaak request, for the flow in `saveDialogFlowAtom` */
|
||||
export function SaveDialog() {
|
||||
const [flow, setFlow] = useAtom(saveDialogFlowAtom);
|
||||
const workspaces = useRpcQuery("list_workspaces", {}, { enabled: flow != null, staleTime: 0 });
|
||||
const close = () => setFlow(null);
|
||||
|
||||
if (flow == null) return null;
|
||||
if (workspaces.data?.yaakFound === false) return <NoYaakDialog flow={flow} onClose={close} />;
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={close}
|
||||
size="dynamic"
|
||||
title="Save as Yaak request"
|
||||
description="A new request in Yaak with this flow's method, URL, headers and body."
|
||||
className="w-[36rem]"
|
||||
>
|
||||
{workspaces.error != null ? (
|
||||
<Banner color="danger">{workspaces.error.message}</Banner>
|
||||
) : workspaces.data == null ? (
|
||||
<LoadingIcon />
|
||||
) : (
|
||||
<SaveForm flow={flow} workspaces={workspaces.data.workspaces} onClose={close} />
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveForm({
|
||||
flow,
|
||||
workspaces,
|
||||
onClose,
|
||||
}: {
|
||||
flow: Flow;
|
||||
workspaces: WorkspaceSummary[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const showToast = useShowToast();
|
||||
const preview = useRpcQuery("preview_save", { flowId: flow.id }, { staleTime: 0 });
|
||||
const [lastWorkspaceId, setLastWorkspaceId] = useLocalStorage<string>("save_workspace_id");
|
||||
const [workspaceId, setWorkspaceId] = useState(
|
||||
workspaces.find((w) => w.id === lastWorkspaceId)?.id ?? workspaces[0]?.id ?? "",
|
||||
);
|
||||
const [folderId, setFolderId] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const save = useRpcMutation("save_flow");
|
||||
|
||||
const workspace = workspaces.find((w) => w.id === workspaceId);
|
||||
const folders = useMemo(() => folderOptions(workspace?.folders ?? []), [workspace]);
|
||||
useEffect(() => setFolderId(""), [workspaceId]);
|
||||
|
||||
const p = preview.data;
|
||||
// Unnamed unless one is typed, as Yaak leaves a new request, which then shows its URL.
|
||||
const unnamed =
|
||||
p == null
|
||||
? ""
|
||||
: unnamedRequestName({ model: "http_request", url: p.requestUrl, bodyType: p.bodyType });
|
||||
const folderName = folders.find((f) => f.id === folderId)?.name;
|
||||
|
||||
const submit = () => {
|
||||
if (workspace == null) return;
|
||||
save.mutate(
|
||||
{
|
||||
flowId: flow.id,
|
||||
workspaceId: workspace.id,
|
||||
...(folderId === "" ? {} : { folderId }),
|
||||
...(name.trim() === "" ? {} : { name }),
|
||||
},
|
||||
{
|
||||
onSuccess: (saved) => {
|
||||
setLastWorkspaceId(workspace.id);
|
||||
onClose();
|
||||
showToast({
|
||||
message: (
|
||||
<>
|
||||
Saved <b className="font-semibold">{saved.name || unnamed}</b> to {workspace.name}
|
||||
{folderName == null ? "." : `, in ${folderName}.`} Yaak picks it up within a second.
|
||||
</>
|
||||
),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
return (
|
||||
<Banner color="notice">
|
||||
Yaak has no workspaces yet. Create one in Yaak, then save here.
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
if (p == null) {
|
||||
return preview.error != null ? (
|
||||
<Banner color="danger">{preview.error.message}</Banner>
|
||||
) : (
|
||||
<LoadingIcon />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
id={SAVE_FORM_ID}
|
||||
className="flex flex-col gap-3.5 pb-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-sm px-2.5 rounded-md border border-border-subtle font-mono text-sm min-w-0">
|
||||
<HttpMethodTagRaw method={flow.method} colored />
|
||||
<span className="truncate text-text">{flow.url}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Workspace">
|
||||
<select
|
||||
className={inputClasses}
|
||||
value={workspaceId}
|
||||
onChange={(e) => setWorkspaceId(e.target.value)}
|
||||
>
|
||||
{workspaces.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Folder">
|
||||
<select
|
||||
className={inputClasses}
|
||||
value={folderId}
|
||||
onChange={(e) => setFolderId(e.target.value)}
|
||||
>
|
||||
<option value="">No folder</option>
|
||||
{folders.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<PlainInput label="Name" autoFocus defaultValue="" placeholder={unnamed} onChange={setName} />
|
||||
{p.droppedHeaders.length > 0 && (
|
||||
<div className="rounded-md border border-border-subtle px-3.5 py-2.5">
|
||||
<div className="text-sm text-text">
|
||||
{p.droppedHeaders.length === 1
|
||||
? "1 header left out"
|
||||
: `${p.droppedHeaders.length} headers left out`}{" "}
|
||||
<span className="text-text-subtlest text-xs">
|
||||
{droppedSummary(p.droppedHeaders.map((h) => h.reason))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{p.droppedHeaders.map((h) => (
|
||||
<span
|
||||
key={h.name}
|
||||
title={`${h.name}: ${h.value}\n${reasonLabel(h.reason)}`}
|
||||
className="font-mono text-xs leading-[22px] px-1.5 rounded-sm border border-border-subtle text-text-subtle line-through decoration-text-subtlest"
|
||||
>
|
||||
{h.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3.5 gap-y-1.5 text-sm">
|
||||
<span className="text-text-subtle">Kept</span>
|
||||
<span>
|
||||
{p.keptHeaders === 1 ? "1 header" : `${p.keptHeaders} headers`}
|
||||
{p.body == null ? ", and no body" : `, and the body as ${p.body}`}
|
||||
</span>
|
||||
{p.warnings.map((w) => (
|
||||
<NoteRow key={w} warning={w} authorization={p.authorization} />
|
||||
))}
|
||||
</div>
|
||||
{save.error != null && <Banner color="danger">{save.error.message}</Banner>}
|
||||
<DialogFooter
|
||||
leftSlot={
|
||||
<span className="text-xs text-text-subtlest">Yaak picks it up within a second</span>
|
||||
}
|
||||
actions={[
|
||||
{ label: "Cancel", onClick: onClose },
|
||||
{
|
||||
label: "Save request",
|
||||
form: SAVE_FORM_ID,
|
||||
color: "primary",
|
||||
isLoading: save.isPending,
|
||||
disabled: workspace == null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteRow({ warning, authorization }: { warning: string; authorization: boolean }) {
|
||||
const flagged = authorization && warning.includes("Authorization");
|
||||
return (
|
||||
<>
|
||||
<span className="text-text-subtle">Note</span>
|
||||
<span className={flagged ? "text-notice" : "text-text-subtle"}>{warning}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Without Yaak there is nothing to save into: say so, and offer HAR as a way to keep the flow */
|
||||
function NoYaakDialog({ flow, onClose }: { flow: Flow; onClose: () => void }) {
|
||||
const showToast = useShowToast();
|
||||
return (
|
||||
<Dialog open onClose={onClose} size="dynamic" className="w-[32rem]">
|
||||
<DialogFooter
|
||||
leftSlot={<span className="text-xs text-text-subtlest">Or keep the flow as a file</span>}
|
||||
actions={[
|
||||
{
|
||||
label: "Export HAR",
|
||||
onClick: async () => {
|
||||
try {
|
||||
if ((await exportHar({ ids: [flow.id] }, "flow.har")) != null) {
|
||||
onClose();
|
||||
showToast({ message: "Exported the flow as HAR" });
|
||||
}
|
||||
} catch (err) {
|
||||
showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ label: "Download Yaak", color: "primary", onClick: () => void openUrl(DOWNLOAD_URL) },
|
||||
]}
|
||||
/>
|
||||
<div className="pt-6 pb-2">
|
||||
<div className="text-lg font-semibold mb-1.5">Saving needs Yaak</div>
|
||||
<div className="text-sm text-text-subtle leading-relaxed">
|
||||
This flow becomes a request in one of Yaak's workspaces, and Yaak has not been opened on
|
||||
this Mac. Yaak is free for personal use and opens the request with the same viewers you
|
||||
see here.
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
// oxlint-disable-next-line jsx-a11y/label-has-associated-control -- wraps its control
|
||||
<label className="flex flex-col gap-1 min-w-0">
|
||||
<span className="text-xs text-text-subtle">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const inputClasses =
|
||||
"h-sm w-full min-w-0 px-2 rounded-md border border-border bg-surface text-text text-sm outline-hidden focus:border-border-focus";
|
||||
|
||||
/** Folders by their path within the workspace, "Stripe / Payments", in path order */
|
||||
function folderOptions(folders: FolderSummary[]): { id: string; name: string }[] {
|
||||
const byId = new Map(folders.map((f) => [f.id, f]));
|
||||
const pathOf = (f: FolderSummary): string => {
|
||||
const names = [f.name];
|
||||
const seen = new Set([f.id]);
|
||||
let parent = f.folderId == null ? undefined : byId.get(f.folderId);
|
||||
while (parent != null && !seen.has(parent.id)) {
|
||||
names.unshift(parent.name);
|
||||
seen.add(parent.id);
|
||||
parent = parent.folderId == null ? undefined : byId.get(parent.folderId);
|
||||
}
|
||||
return names.join(" / ");
|
||||
};
|
||||
return folders
|
||||
.map((f) => ({ id: f.id, name: pathOf(f) }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function reasonLabel(reason: DropReason): string {
|
||||
switch (reason) {
|
||||
case "set_by_sending":
|
||||
return "Yaak sets it when it sends";
|
||||
case "hop_by_hop":
|
||||
return "Only meant for the proxy";
|
||||
case "body_decoded":
|
||||
return "The body is saved decoded";
|
||||
}
|
||||
}
|
||||
|
||||
function droppedSummary(reasons: DropReason[]): string {
|
||||
const unique = [...new Set(reasons)];
|
||||
if (unique.length === 1 && unique[0] === "set_by_sending") {
|
||||
return "Yaak sets these itself when it sends";
|
||||
}
|
||||
return "Yaak sets these itself, or they only meant something to the proxy";
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Icon, IconButton, PlainInput } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { exportHar } from "../lib/flowActions";
|
||||
import { searchTerms } from "../lib/search";
|
||||
import { activeQueryAtom, searchTermsAtom } from "../lib/store";
|
||||
import { useShowToast } from "../lib/toast";
|
||||
|
||||
/** How long typing settles before the search runs */
|
||||
const SEARCH_DEBOUNCE_MS = 200;
|
||||
|
||||
/**
|
||||
* Searches every flow in the database by URL, method, status and content type, within the
|
||||
* sidebar's selection. ⌘F focuses it and Escape clears it.
|
||||
*/
|
||||
export const SearchBar = memo(function SearchBar() {
|
||||
const [text, setText] = useState("");
|
||||
const setTerms = useSetAtom(searchTermsAtom);
|
||||
const query = useAtomValue(activeQueryAtom);
|
||||
const showToast = useShowToast();
|
||||
// PlainInput hands back its <input> as the ref
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// Bumped to empty the field, which only takes a value from outside when this changes
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setTerms(searchTerms(text)), SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [text, setTerms]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "f") {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
// The field is replaced to empty it, so focus moves to the new one.
|
||||
const clear = () => {
|
||||
setText("");
|
||||
setTerms(null);
|
||||
setResetKey((k) => k + 1);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-3 pb-1.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<PlainInput
|
||||
ref={inputRef}
|
||||
label="Search flows"
|
||||
hideLabel
|
||||
size="xs"
|
||||
placeholder="Search flows by URL, method, status or type"
|
||||
defaultValue={text}
|
||||
forceUpdateKey={String(resetKey)}
|
||||
onChange={setText}
|
||||
onKeyDownCapture={(e) => {
|
||||
if (e.key === "Escape" && text !== "") {
|
||||
e.stopPropagation();
|
||||
clear();
|
||||
}
|
||||
}}
|
||||
leftSlot={
|
||||
<Icon icon="search" size="sm" className="ml-2 mr-2.5 self-center text-text-subtlest" />
|
||||
}
|
||||
rightSlot={
|
||||
text === "" ? (
|
||||
<kbd className="self-center mr-2 px-1 rounded-sm border border-border-subtle font-sans text-2xs text-text-subtlest">
|
||||
⌘F
|
||||
</kbd>
|
||||
) : (
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="x"
|
||||
title="Clear search"
|
||||
iconColor="secondary"
|
||||
className="self-center"
|
||||
onClick={clear}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="download"
|
||||
iconColor="secondary"
|
||||
title={query == null ? "Export every flow as HAR" : "Export the flows shown as HAR"}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const written = await exportHar(query == null ? {} : { filter: query }, "flows.har");
|
||||
if (written != null) {
|
||||
showToast({
|
||||
message:
|
||||
written === 1 ? "Exported 1 flow as HAR" : `Exported ${written} flows as HAR`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -2,61 +2,87 @@ import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||
import type { TreeNode } from "@yaakapp-internal/ui";
|
||||
import { selectedIdsFamily, Tree } from "@yaakapp-internal/ui";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import { atomFamily } from "jotai-family";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
import { useCallback } from "react";
|
||||
import { flowsAtom } from "../lib/store";
|
||||
import { SIDEBAR_TREE_ID } from "../lib/atoms";
|
||||
import { appKeyOf, appNodeId, sidebarPath } from "../lib/search";
|
||||
import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse";
|
||||
import { flowsAtom, flowTotalAtom } from "../lib/store";
|
||||
import { AppIcon } from "./AppIcon";
|
||||
|
||||
/** A node in the sidebar tree — either a domain or a path segment. */
|
||||
/** A node in the sidebar tree: a domain, a path segment, or an app. */
|
||||
export type SidebarItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
exchangeIds: string[];
|
||||
/** Flows held under this node; for the Domains folder, every flow in the database */
|
||||
count: number;
|
||||
/** An app's path, for its icon */
|
||||
appPath?: string;
|
||||
};
|
||||
|
||||
const collapsedAtom = atomFamily((_treeId: string) => atom<Record<string, boolean>>({}));
|
||||
|
||||
export const SIDEBAR_TREE_ID = "proxy-sidebar";
|
||||
|
||||
const sidebarTreeAtom = atom<TreeNode<SidebarItem>>((get) => {
|
||||
const exchanges = get(flowsAtom);
|
||||
return buildTree(exchanges);
|
||||
return buildTree(exchanges, get(flowTotalAtom));
|
||||
});
|
||||
|
||||
/** Exchanges filtered by the currently selected sidebar node(s). */
|
||||
export const filteredExchangesAtom = atom((get) => {
|
||||
const exchanges = get(flowsAtom);
|
||||
const tree = get(sidebarTreeAtom);
|
||||
const selectedIds = get(selectedIdsFamily(SIDEBAR_TREE_ID));
|
||||
|
||||
// Nothing selected or root selected → show all
|
||||
if (selectedIds.length === 0 || selectedIds.includes("root")) {
|
||||
return exchanges;
|
||||
}
|
||||
|
||||
// Collect exchange IDs from all selected nodes
|
||||
const allowedIds = new Set<string>();
|
||||
const nodeMap = new Map<string, SidebarItem>();
|
||||
collectNodes(tree, nodeMap);
|
||||
|
||||
for (const selectedId of selectedIds) {
|
||||
const node = nodeMap.get(selectedId);
|
||||
if (node) {
|
||||
for (const id of node.exchangeIds) {
|
||||
allowedIds.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return exchanges.filter((ex) => allowedIds.has(ex.id));
|
||||
/** The domains and paths the user expanded, by node id, kept across launches */
|
||||
const expandedAtom = atomWithStorage<Record<string, boolean>>("sidebar_expanded", {}, undefined, {
|
||||
getOnInit: true,
|
||||
});
|
||||
|
||||
function collectNodes(node: TreeNode<SidebarItem>, map: Map<string, SidebarItem>) {
|
||||
map.set(node.item.id, node.item);
|
||||
for (const child of node.children ?? []) {
|
||||
collectNodes(child, map);
|
||||
/** The other nodes' collapsed flags, for this launch, as before */
|
||||
const otherCollapsedAtom = atom<CollapsedMap>({});
|
||||
|
||||
/** Domains and paths collapsed until the user expands them, for the tree */
|
||||
const collapsedAtom = atom(
|
||||
(get) =>
|
||||
collapsedMap(folderIds(get(sidebarTreeAtom)), get(expandedAtom), get(otherCollapsedAtom)),
|
||||
(get, set, action: CollapsedMap | ((prev: CollapsedMap) => CollapsedMap)) => {
|
||||
const prev = get(collapsedAtom);
|
||||
const next = typeof action === "function" ? action(prev) : action;
|
||||
const split = applyCollapsed(prev, next, get(expandedAtom), get(otherCollapsedAtom));
|
||||
set(expandedAtom, split.expanded);
|
||||
set(otherCollapsedAtom, split.others);
|
||||
},
|
||||
);
|
||||
|
||||
/** The selected sidebar node's name, for the header */
|
||||
export const selectedSidebarLabelAtom = atom((get) => {
|
||||
const selectedId = get(selectedIdsFamily(SIDEBAR_TREE_ID))[0];
|
||||
if (selectedId?.startsWith("domain:")) return selectedId.slice("domain:".length);
|
||||
if (selectedId?.startsWith("path:")) return selectedId.slice("path:".length);
|
||||
if (selectedId?.startsWith("app:")) {
|
||||
const apps = get(sidebarTreeAtom).children?.find((c) => c.item.id === "apps");
|
||||
return apps?.children?.find((c) => c.item.id === selectedId)?.item.label ?? "All traffic";
|
||||
}
|
||||
return "All traffic";
|
||||
});
|
||||
|
||||
const nodeIdsByFlow = new WeakMap<Flow, string[]>();
|
||||
|
||||
/**
|
||||
* The ids of the nodes a flow sits under, its domain first and then each path segment's, parsed
|
||||
* once per flow rather than on every change to the list.
|
||||
*/
|
||||
function nodeIdsOf(ex: Flow): string[] {
|
||||
let ids = nodeIdsByFlow.get(ex);
|
||||
if (ids != null) return ids;
|
||||
// By the rules the database filters by, so a node shows the flows it counts.
|
||||
const path = sidebarPath(ex.url);
|
||||
const schemeless = !ex.url.includes("://");
|
||||
const [hostname = "", ...segments] = schemeless ? [path] : path.split("/");
|
||||
ids = [`domain:${hostname}`];
|
||||
let node = `path:${hostname}`;
|
||||
for (const seg of segments) {
|
||||
node += `/${seg}`;
|
||||
ids.push(node);
|
||||
}
|
||||
nodeIdsByFlow.set(ex, ids);
|
||||
return ids;
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator();
|
||||
|
||||
/**
|
||||
* Build a domain → path-segment trie from a flat list of exchanges.
|
||||
*
|
||||
@@ -72,8 +98,8 @@ function collectNodes(node: TreeNode<SidebarItem>, map: Map<string, SidebarItem>
|
||||
* /123
|
||||
* /orders
|
||||
*/
|
||||
function buildTree(exchanges: Flow[]): TreeNode<SidebarItem> {
|
||||
const root: SidebarItem = { id: "root", label: "All Traffic", exchangeIds: [] };
|
||||
function buildTree(exchanges: Flow[], total: number): TreeNode<SidebarItem> {
|
||||
const root: SidebarItem = { id: "root", label: "All Traffic", count: 0 };
|
||||
const rootNode: TreeNode<SidebarItem> = {
|
||||
item: root,
|
||||
parent: null,
|
||||
@@ -86,54 +112,26 @@ function buildTree(exchanges: Flow[]): TreeNode<SidebarItem> {
|
||||
type TrieNode = {
|
||||
id: string;
|
||||
label: string;
|
||||
exchangeIds: string[];
|
||||
count: number;
|
||||
children: Map<string, TrieNode>;
|
||||
};
|
||||
|
||||
const domainMap = new Map<string, TrieNode>();
|
||||
|
||||
for (const ex of exchanges) {
|
||||
let hostname: string;
|
||||
let segments: string[];
|
||||
try {
|
||||
const url = new URL(ex.url);
|
||||
hostname = url.host;
|
||||
segments = url.pathname.split("/").filter(Boolean);
|
||||
} catch {
|
||||
hostname = ex.url;
|
||||
segments = [];
|
||||
}
|
||||
|
||||
// Get or create domain node
|
||||
let domainNode = domainMap.get(hostname);
|
||||
if (!domainNode) {
|
||||
domainNode = {
|
||||
id: `domain:${hostname}`,
|
||||
label: hostname,
|
||||
exchangeIds: [],
|
||||
children: new Map(),
|
||||
};
|
||||
domainMap.set(hostname, domainNode);
|
||||
}
|
||||
domainNode.exchangeIds.push(ex.id);
|
||||
|
||||
// Walk path segments
|
||||
let current = domainNode;
|
||||
const pathSoFar: string[] = [];
|
||||
for (const seg of segments) {
|
||||
pathSoFar.push(seg);
|
||||
let child = current.children.get(seg);
|
||||
if (!child) {
|
||||
child = {
|
||||
id: `path:${hostname}/${pathSoFar.join("/")}`,
|
||||
label: `/${seg}`,
|
||||
exchangeIds: [],
|
||||
children: new Map(),
|
||||
};
|
||||
current.children.set(seg, child);
|
||||
const ids = nodeIdsOf(ex);
|
||||
let current: TrieNode | undefined;
|
||||
for (const id of ids) {
|
||||
const siblings = current == null ? domainMap : current.children;
|
||||
let node = siblings.get(id);
|
||||
if (node == null) {
|
||||
// The domain, or the path's last segment
|
||||
const label = current == null ? id.slice("domain:".length) : id.slice(id.lastIndexOf("/"));
|
||||
node = { id, label, count: 0, children: new Map() };
|
||||
siblings.set(id, node);
|
||||
}
|
||||
child.exchangeIds.push(ex.id);
|
||||
current = child;
|
||||
node.count++;
|
||||
current = node;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +145,7 @@ function buildTree(exchanges: Flow[]): TreeNode<SidebarItem> {
|
||||
item: {
|
||||
id: trie.id,
|
||||
label: trie.label,
|
||||
exchangeIds: trie.exchangeIds,
|
||||
count: trie.count,
|
||||
},
|
||||
parent,
|
||||
depth,
|
||||
@@ -155,7 +153,7 @@ function buildTree(exchanges: Flow[]): TreeNode<SidebarItem> {
|
||||
draggable: false,
|
||||
};
|
||||
const sortedChildren = [...trie.children.values()].sort((a, b) =>
|
||||
a.label.localeCompare(b.label),
|
||||
collator.compare(a.label, b.label),
|
||||
);
|
||||
for (const child of sortedChildren) {
|
||||
node.children?.push(toTreeNode(child, node, depth + 1));
|
||||
@@ -164,29 +162,57 @@ function buildTree(exchanges: Flow[]): TreeNode<SidebarItem> {
|
||||
}
|
||||
|
||||
// Add a "Domains" folder between root and domain nodes
|
||||
const allExchangeIds = exchanges.map((ex) => ex.id);
|
||||
const domainsFolder: TreeNode<SidebarItem> = {
|
||||
item: { id: "domains", label: "Domains", exchangeIds: allExchangeIds },
|
||||
item: { id: "domains", label: "Domains", count: total },
|
||||
parent: rootNode,
|
||||
depth: 1,
|
||||
children: [],
|
||||
draggable: false,
|
||||
};
|
||||
|
||||
const sortedDomains = [...domainMap.values()].sort((a, b) => a.label.localeCompare(b.label));
|
||||
const sortedDomains = [...domainMap.values()].sort((a, b) => collator.compare(a.label, b.label));
|
||||
for (const domain of sortedDomains) {
|
||||
domainsFolder.children?.push(toTreeNode(domain, domainsFolder, 2));
|
||||
}
|
||||
|
||||
// The apps flows came from, where the platform can tell; none on others, so no group.
|
||||
const apps = new Map<string, SidebarItem>();
|
||||
for (const ex of exchanges) {
|
||||
const key = appKeyOf(ex);
|
||||
if (key == null) continue;
|
||||
const id = appNodeId(key);
|
||||
const app = apps.get(id);
|
||||
if (app == null) {
|
||||
apps.set(id, { id, label: ex.clientName ?? key, count: 1, appPath: ex.clientPath ?? key });
|
||||
} else {
|
||||
app.count++;
|
||||
}
|
||||
}
|
||||
if (apps.size > 0) {
|
||||
const appsFolder: TreeNode<SidebarItem> = {
|
||||
item: { id: "apps", label: "Apps", count: 0 },
|
||||
parent: rootNode,
|
||||
depth: 1,
|
||||
children: [],
|
||||
draggable: false,
|
||||
};
|
||||
const sortedApps = [...apps.values()].sort((a, b) => collator.compare(a.label, b.label));
|
||||
for (const item of sortedApps) {
|
||||
appsFolder.children?.push({ item, parent: appsFolder, depth: 2, draggable: false });
|
||||
}
|
||||
rootNode.children?.push(appsFolder);
|
||||
}
|
||||
|
||||
rootNode.children?.push(domainsFolder);
|
||||
|
||||
return rootNode;
|
||||
}
|
||||
|
||||
function ItemInner({ item }: { item: SidebarItem }) {
|
||||
const count = item.exchangeIds.length;
|
||||
const count = item.count;
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full min-w-0">
|
||||
{item.appPath != null && <AppIcon path={item.appPath} />}
|
||||
<span className="truncate">{item.label}</span>
|
||||
{count > 0 && <span className="ml-auto text-text-subtlest text-2xs shrink-0">{count}</span>}
|
||||
</div>
|
||||
@@ -195,25 +221,22 @@ function ItemInner({ item }: { item: SidebarItem }) {
|
||||
|
||||
export function Sidebar() {
|
||||
const tree = useAtomValue(sidebarTreeAtom);
|
||||
|
||||
const getItemKey = useCallback((item: SidebarItem) => `${item.id}:${item.count}`, []);
|
||||
const treeId = SIDEBAR_TREE_ID;
|
||||
|
||||
const getItemKey = useCallback(
|
||||
(item: SidebarItem) => `${item.id}:${item.exchangeIds.length}`,
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="x-theme-sidebar bg-surface h-full w-full min-w-0 overflow-y-auto border-r border-border-subtle">
|
||||
<div className="pt-2 text-xs">
|
||||
<Tree
|
||||
treeId={treeId}
|
||||
collapsedAtom={collapsedAtom(treeId)}
|
||||
className="px-2 pb-10"
|
||||
root={tree}
|
||||
getItemKey={getItemKey}
|
||||
ItemInner={ItemInner}
|
||||
/>
|
||||
</div>
|
||||
// The tree scrolls itself, in a row of bounded height, so its virtualizer mounts only the rows
|
||||
// in view rather than every domain and path.
|
||||
<aside className="x-theme-sidebar bg-surface h-full w-full min-w-0 grid grid-rows-[minmax(0,1fr)] border-r border-border-subtle">
|
||||
<Tree
|
||||
treeId={treeId}
|
||||
collapsedAtom={collapsedAtom}
|
||||
className="px-2 pt-2 pb-10"
|
||||
root={tree}
|
||||
getItemKey={getItemKey}
|
||||
ItemInner={ItemInner}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Button, Icon } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
import { proxyStateAtom } from "../lib/store";
|
||||
|
||||
/**
|
||||
* A quiet strip while another process, the CLI daemon, owns the engine: flows still list, read
|
||||
* from the database, and Take over stops that process's proxy and starts this app's. Never done
|
||||
* without the person asking.
|
||||
*/
|
||||
export function TakeOverBanner() {
|
||||
const other = useAtomValue(proxyStateAtom)?.otherOwner;
|
||||
const takeOver = useRpcMutation("take_over");
|
||||
if (other == null) return null;
|
||||
const since = new Date(other.startedAt).toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const cli = other.owner === "cli";
|
||||
const who = cli ? "Running from the CLI" : "Running in another Yaak Proxy";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 h-[2.2rem] mx-2 mb-1 pl-3 pr-1.5 rounded-md bg-surface-highlight/45 border border-border-subtle text-sm text-text-subtle min-w-0">
|
||||
<Icon icon="square_terminal" size="sm" color="info" className="shrink-0" />
|
||||
<span className="text-text shrink-0">{who}</span>
|
||||
{other.owner === "cli" && (
|
||||
<span className="font-mono text-xs text-text-subtlest shrink-0">yaak proxy start</span>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{!cli ? (
|
||||
// Stopping another app halts its engine without releasing it, so it can't be taken over.
|
||||
"Another Yaak Proxy window owns the engine. Quit it to use this one."
|
||||
) : takeOver.error != null ? (
|
||||
<span className="text-danger">{takeOver.error.message}</span>
|
||||
) : (
|
||||
`pid ${other.pid}, since ${since}. Flows still list here, read from the capture database.`
|
||||
)}
|
||||
</span>
|
||||
{cli && (
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
className="ml-auto shrink-0"
|
||||
isLoading={takeOver.isPending}
|
||||
onClick={() => takeOver.mutate({})}
|
||||
>
|
||||
Take over
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
// Hardcode font size for now. In the future, this could be configurable.
|
||||
document.documentElement.style.fontSize = "15px";
|
||||
document.documentElement.style.fontSize = "14px";
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||
import type { SplitLayoutLayout } from "@yaakapp-internal/ui";
|
||||
import { atom } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
|
||||
/** The sidebar's tree, whose selection narrows the list */
|
||||
export const SIDEBAR_TREE_ID = "proxy-sidebar";
|
||||
|
||||
/** The selected flow as last seen, kept once the window has dropped it so the detail stays */
|
||||
export const selectedFlowAtom = atom<Flow | null>(null);
|
||||
|
||||
export const selectedFlowIdAtom = atom((get) => get(selectedFlowAtom)?.id ?? null);
|
||||
|
||||
/** The list follows new traffic, rather than being held where it was scrolled or selected */
|
||||
export const followingAtom = atom(true);
|
||||
|
||||
/** When the helper was last registered, so its state is read until approval shows */
|
||||
export const helperRegisteredAtAtom = atom<number | null>(null);
|
||||
|
||||
/** The dialog that walks through allowing the helper in System Settings */
|
||||
export const helperDialogOpenAtom = atom(false);
|
||||
|
||||
/** Stacked by default; the header's toggle switches to side by side */
|
||||
export const flowLayoutAtom = atomWithStorage<SplitLayoutLayout>("flow_layout", "vertical");
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { bytesToText } from "./body";
|
||||
|
||||
const encode = (text: string) => new TextEncoder().encode(text);
|
||||
|
||||
describe("bytesToText", () => {
|
||||
test("decodes UTF-8", () => {
|
||||
expect(bytesToText(encode("abc€"))).toBe("abc€");
|
||||
});
|
||||
|
||||
test("rejects a body that is not UTF-8", () => {
|
||||
expect(bytesToText(new Uint8Array([0xff, 0xfe, 0x00]))).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps a truncated body cut partway through a character", () => {
|
||||
const cut = encode("abc€").subarray(0, 5);
|
||||
expect(bytesToText(cut)).toBeNull();
|
||||
expect(bytesToText(cut, true)).toBe("abc");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { FilterLanguage, FlowBody } from "@yaakapp-internal/proxy-lib";
|
||||
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
|
||||
import vkBeautify from "vkbeautify";
|
||||
import { rpc } from "./rpc";
|
||||
|
||||
export function base64ToBytes(data: string): Uint8Array<ArrayBuffer> {
|
||||
const binary = atob(data);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of a body, or null when it isn't valid UTF-8. A truncated body may end partway
|
||||
* through a character, so up to three trailing bytes are dropped before giving up on it.
|
||||
*/
|
||||
export function bytesToText(bytes: Uint8Array, truncated = false): string | null {
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
const maxTrim = truncated ? Math.min(3, bytes.length) : 0;
|
||||
for (let trim = 0; trim <= maxTrim; trim++) {
|
||||
try {
|
||||
return decoder.decode(bytes.subarray(0, bytes.length - trim));
|
||||
} catch {
|
||||
// Try again without the last byte
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function formatText(
|
||||
text: string,
|
||||
language: CodeEditorProps["language"],
|
||||
): Promise<string> {
|
||||
if (text === "") return text;
|
||||
try {
|
||||
if (language === "json") return await rpc("format_body", { text });
|
||||
if (language === "xml" || language === "html") return vkBeautify.xml(text, " ");
|
||||
} catch (err) {
|
||||
console.warn("Failed to format body", err);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** The filter syntax for a body the viewer detected as `language`: XPath for XML and HTML */
|
||||
export function filterLanguage(language: CodeEditorProps["language"]): FilterLanguage | undefined {
|
||||
if (language === "json") return "jsonpath";
|
||||
if (language === "xml" || language === "html") return "xpath";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function filterText(
|
||||
flowId: string,
|
||||
which: FlowBody,
|
||||
expression: string,
|
||||
language: CodeEditorProps["language"],
|
||||
): Promise<string> {
|
||||
const result = await rpc("filter_body", {
|
||||
flowId,
|
||||
which,
|
||||
expression,
|
||||
language: filterLanguage(language),
|
||||
});
|
||||
if (result.error != null) throw new Error(result.error);
|
||||
return result.content;
|
||||
}
|
||||
|
||||
/** The media type without parameters, lowercased: `application/json` */
|
||||
export function mimeEssence(contentType: string | null): string | null {
|
||||
const essence = contentType?.split(";")[0]?.trim().toLowerCase();
|
||||
return essence ? essence : null;
|
||||
}
|
||||
|
||||
/** What the flow list shows for a content type: `json` for `application/problem+json` */
|
||||
export function shortContentType(contentType: string | null): string | null {
|
||||
const essence = mimeEssence(contentType);
|
||||
if (essence == null) return null;
|
||||
const subtype = essence.split("/")[1] ?? essence;
|
||||
const suffix = subtype.split("+").pop() ?? subtype;
|
||||
return suffix.replace(/^x-/, "");
|
||||
}
|
||||
|
||||
export function findHeader(headers: { name: string; value: string }[], name: string) {
|
||||
const lower = name.toLowerCase();
|
||||
return headers.find((h) => h.name.toLowerCase() === lower)?.value ?? null;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { curlBodyText, shellQuote, toCurl } from "./curl";
|
||||
|
||||
const headers = (...pairs: [string, string][]) => pairs.map(([name, value]) => ({ name, value }));
|
||||
|
||||
describe("toCurl", () => {
|
||||
test("a GET is the URL and its headers, without the ones curl sets itself", () => {
|
||||
const flow = {
|
||||
method: "GET",
|
||||
url: "https://api.github.com/repos/yaak?page=2",
|
||||
requestHeaders: headers(
|
||||
["Host", "api.github.com"],
|
||||
["Accept", "application/json"],
|
||||
["Proxy-Connection", "keep-alive"],
|
||||
["Connection", "keep-alive"],
|
||||
),
|
||||
};
|
||||
expect(toCurl(flow, null)).toBe(
|
||||
"curl 'https://api.github.com/repos/yaak?page=2' \\\n -H 'Accept: application/json'",
|
||||
);
|
||||
});
|
||||
|
||||
test("a POST with a body leaves the method to curl and quotes the body", () => {
|
||||
const flow = {
|
||||
method: "POST",
|
||||
url: "https://api.stripe.com/v1/charges",
|
||||
requestHeaders: headers(
|
||||
["Authorization", "Bearer sk_test_123"],
|
||||
["Content-Type", "application/json"],
|
||||
["Content-Length", "23"],
|
||||
["Accept-Encoding", "gzip, br"],
|
||||
),
|
||||
};
|
||||
expect(toCurl(flow, `{"note":"it's paid"}`)).toBe(
|
||||
[
|
||||
"curl 'https://api.stripe.com/v1/charges'",
|
||||
"-H 'Authorization: Bearer sk_test_123'",
|
||||
"-H 'Content-Type: application/json'",
|
||||
"--compressed",
|
||||
`--data-raw '{"note":"it'\\''s paid"}'`,
|
||||
].join(" \\\n "),
|
||||
);
|
||||
});
|
||||
|
||||
test("quotes a method that is not a plain word, and drops the decoded body's encoding", () => {
|
||||
const flow = {
|
||||
method: "`id`",
|
||||
url: "https://x.test/a",
|
||||
requestHeaders: headers(["Content-Encoding", "gzip"], ["X-Trace", "1"]),
|
||||
};
|
||||
expect(toCurl(flow, "plain")).toBe(
|
||||
"curl -X '`id`' \\\n 'https://x.test/a' \\\n -H 'X-Trace: 1' \\\n --data-raw 'plain'",
|
||||
);
|
||||
});
|
||||
|
||||
test("replays HEAD with --head and leaves brackets in the URL alone", () => {
|
||||
const flow = {
|
||||
method: "HEAD",
|
||||
url: "https://x.test/a?filter[status]=open",
|
||||
requestHeaders: [],
|
||||
};
|
||||
expect(toCurl(flow, null)).toBe(
|
||||
"curl --head \\\n --globoff \\\n 'https://x.test/a?filter[status]=open'",
|
||||
);
|
||||
});
|
||||
|
||||
test("names any other method", () => {
|
||||
const flow = { method: "DELETE", url: "https://x.test/a", requestHeaders: [] };
|
||||
expect(toCurl(flow, null)).toBe("curl -X DELETE \\\n 'https://x.test/a'");
|
||||
expect(toCurl({ ...flow, method: "GET" }, "q=1")).toBe(
|
||||
"curl -X GET \\\n 'https://x.test/a' \\\n --data-raw 'q=1'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shellQuote", () => {
|
||||
test("closes and reopens the quotes around a quote", () => {
|
||||
expect(shellQuote("a'b")).toBe(`'a'\\''b'`);
|
||||
expect(shellQuote("$HOME `x`")).toBe("'$HOME `x`'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("curlBodyText", () => {
|
||||
test("is the text of a complete UTF-8 body", () => {
|
||||
expect(curlBodyText(new TextEncoder().encode('{"note":"héllo"}'), false)).toBe(
|
||||
'{"note":"héllo"}',
|
||||
);
|
||||
});
|
||||
|
||||
test("refuses a body curl would send different bytes for", () => {
|
||||
expect(curlBodyText(new Uint8Array([0xff, 0x00, 0xfe]), false)).toBeNull();
|
||||
expect(curlBodyText(new TextEncoder().encode("the first part"), true)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||
|
||||
/** Headers curl works out itself, or that only meant something between the app and the proxy */
|
||||
const LEFT_OUT = new Set([
|
||||
"host",
|
||||
"content-length",
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
]);
|
||||
|
||||
/**
|
||||
* A request body as the text a curl command can carry, or null when it would not send the same
|
||||
* bytes: when it is not valid UTF-8, or only its start was captured.
|
||||
*/
|
||||
export function curlBodyText(bytes: Uint8Array, truncated: boolean): string | null {
|
||||
if (truncated) return null;
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `value` in single quotes for a POSIX shell */
|
||||
export function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The flow's request as a curl command: its method, URL, headers and `body`, the request body
|
||||
* decoded. Headers curl sets itself are left out, and an encoded response is asked for with
|
||||
* `--compressed`, as the app did.
|
||||
*/
|
||||
export function toCurl(flow: Pick<Flow, "method" | "url" | "requestHeaders">, body: string | null) {
|
||||
const args: string[] = [];
|
||||
const hasBody = body != null && body !== "";
|
||||
const defaultMethod = hasBody ? "POST" : "GET";
|
||||
if (flow.method.toUpperCase() === "HEAD" && !hasBody) {
|
||||
// So curl expects no body after the headers.
|
||||
args.push("--head");
|
||||
} else if (flow.method.toUpperCase() !== defaultMethod) {
|
||||
// A method is any token, which can hold characters a shell would act on.
|
||||
const plain = /^[A-Za-z0-9_-]+$/.test(flow.method);
|
||||
args.push(`-X ${plain ? flow.method : shellQuote(flow.method)}`);
|
||||
}
|
||||
// curl reads brackets and braces in a URL as ranges and sets unless told not to.
|
||||
if (/[[\]{}]/.test(flow.url)) args.push("--globoff");
|
||||
args.push(shellQuote(flow.url));
|
||||
let compressed = false;
|
||||
for (const { name, value } of flow.requestHeaders) {
|
||||
const lower = name.toLowerCase();
|
||||
if (LEFT_OUT.has(lower) || lower.startsWith("proxy-") || lower.startsWith(":")) continue;
|
||||
// The body is the decoded one, so its encoding no longer applies.
|
||||
if (lower === "content-encoding" && hasBody) continue;
|
||||
if (lower === "accept-encoding") {
|
||||
compressed = true;
|
||||
continue;
|
||||
}
|
||||
args.push(`-H ${shellQuote(`${name}: ${value}`)}`);
|
||||
}
|
||||
if (compressed) args.push("--compressed");
|
||||
if (hasBody) args.push(`--data-raw ${shellQuote(body)}`);
|
||||
return `curl ${args.join(" \\\n ")}`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { environmentExports } from "./env";
|
||||
|
||||
describe("environmentExports", () => {
|
||||
test("points the proxy variables at the port and the CA variables at the certificate", () => {
|
||||
const path = "/Users/me/Library/Application Support/app.yaak.proxy/ca/ca.pem";
|
||||
expect(environmentExports(9090, path)).toBe(
|
||||
[
|
||||
"export HTTP_PROXY=http://127.0.0.1:9090",
|
||||
"export HTTPS_PROXY=http://127.0.0.1:9090",
|
||||
"export http_proxy=http://127.0.0.1:9090",
|
||||
"export https_proxy=http://127.0.0.1:9090",
|
||||
`export SSL_CERT_FILE='${path}'`,
|
||||
`export NODE_EXTRA_CA_CERTS='${path}'`,
|
||||
`export REQUESTS_CA_BUNDLE='${path}'`,
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { shellQuote } from "./curl";
|
||||
|
||||
/**
|
||||
* Shell exports that send one terminal's traffic through the proxy on `port` and trust its CA at
|
||||
* `caPath`, for tools that read neither the system proxy nor the login keychain: Node, Python,
|
||||
* and anything built on OpenSSL.
|
||||
*/
|
||||
export function environmentExports(port: number, caPath: string): string {
|
||||
const proxy = `http://127.0.0.1:${port}`;
|
||||
const ca = shellQuote(caPath);
|
||||
return [
|
||||
`export HTTP_PROXY=${proxy}`,
|
||||
`export HTTPS_PROXY=${proxy}`,
|
||||
// curl reads only the lowercase name for plain HTTP.
|
||||
`export http_proxy=${proxy}`,
|
||||
`export https_proxy=${proxy}`,
|
||||
`export SSL_CERT_FILE=${ca}`,
|
||||
`export NODE_EXTRA_CA_CERTS=${ca}`,
|
||||
`export REQUESTS_CA_BUNDLE=${ca}`,
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { ExportHarRequest, Flow, FlowBody } from "@yaakapp-internal/proxy-lib";
|
||||
import type { DropdownItem } from "@yaakapp-internal/ui";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { atom, useSetAtom } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { base64ToBytes } from "./body";
|
||||
import { curlBodyText, toCurl } from "./curl";
|
||||
import { rpc } from "./rpc";
|
||||
import { useShowToast } from "./toast";
|
||||
|
||||
/** The flow the save dialog is open for */
|
||||
export const saveDialogFlowAtom = atom<Flow | null>(null);
|
||||
|
||||
/** A body as text, decoding what is not UTF-8 lossily, or null when there is none */
|
||||
async function bodyText(flow: Flow, which: FlowBody): Promise<string | null> {
|
||||
const res = await rpc("get_flow_body", { flowId: flow.id, which });
|
||||
const bytes = base64ToBytes(res.data);
|
||||
return bytes.length === 0 ? null : new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes flows to a HAR file the person picks: `ids`, or every flow `filter` covers. Returns how
|
||||
* many were written, or null when the save panel was cancelled.
|
||||
*/
|
||||
export async function exportHar(
|
||||
req: Omit<ExportHarRequest, "path">,
|
||||
defaultName: string,
|
||||
): Promise<number | null> {
|
||||
const path = await save({
|
||||
defaultPath: defaultName,
|
||||
filters: [{ name: "HTTP Archive", extensions: ["har"] }],
|
||||
});
|
||||
if (path == null) return null;
|
||||
return rpc("export_har", { ...req, path });
|
||||
}
|
||||
|
||||
/** What a flow's context menu and the detail pane's menu offer */
|
||||
export function useFlowActions(): (flow: Flow) => DropdownItem[] {
|
||||
const showToast = useShowToast();
|
||||
const openSave = useSetAtom(saveDialogFlowAtom);
|
||||
|
||||
return useCallback(
|
||||
(flow: Flow) => {
|
||||
const copy = async (label: string, text: () => Promise<string | null> | string) => {
|
||||
try {
|
||||
const value = await text();
|
||||
if (value == null) {
|
||||
showToast({ message: `There is no ${label} to copy`, color: "info" });
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(value);
|
||||
showToast({ message: `Copied ${label}` });
|
||||
} catch (err) {
|
||||
showToast({ message: `Failed to copy ${label}: ${String(err)}`, color: "danger" });
|
||||
}
|
||||
};
|
||||
const noResponse = (flow.error != null || flow.cancelled) && flow.status == null;
|
||||
return [
|
||||
{
|
||||
label: flow.savedRequestId == null ? "Save as Yaak Request" : "Save to Yaak Again",
|
||||
icon: "bookmark",
|
||||
onSelect: () => openSave(flow),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Copy as cURL",
|
||||
icon: "square_terminal",
|
||||
onSelect: async () => {
|
||||
let body: string | null = null;
|
||||
if (flow.requestSize > 0) {
|
||||
let res;
|
||||
try {
|
||||
res = await rpc("get_flow_body", { flowId: flow.id, which: "request" });
|
||||
} catch (err) {
|
||||
showToast({ message: `Failed to copy cURL: ${String(err)}`, color: "danger" });
|
||||
return;
|
||||
}
|
||||
body = curlBodyText(base64ToBytes(res.data), res.truncated);
|
||||
// A command that sends other bytes than the app did would mislead.
|
||||
if (body == null) {
|
||||
showToast({
|
||||
message: "Body is binary or truncated; export HAR instead",
|
||||
color: "info",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
await copy("cURL", () => toCurl(flow, body));
|
||||
},
|
||||
},
|
||||
{ label: "Copy URL", icon: "copy", onSelect: () => copy("URL", () => flow.url) },
|
||||
{
|
||||
label: "Copy Request Body",
|
||||
icon: "copy",
|
||||
disabled: flow.requestSize === 0,
|
||||
onSelect: () => copy("request body", () => bodyText(flow, "request")),
|
||||
},
|
||||
{
|
||||
label: "Copy Response Body",
|
||||
icon: "copy",
|
||||
disabled: noResponse,
|
||||
onSelect: () => copy("response body", () => bodyText(flow, "response")),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Export HAR",
|
||||
icon: "download",
|
||||
onSelect: async () => {
|
||||
try {
|
||||
const written = await exportHar({ ids: [flow.id] }, "flow.har");
|
||||
if (written != null) showToast({ message: "Exported the flow as HAR" });
|
||||
} catch (err) {
|
||||
showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" });
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
[openSave, showToast],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { Flow, ModelPayload } from "@yaakapp-internal/proxy-lib";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
applyChanges,
|
||||
emptyWindow,
|
||||
type FlowWindow,
|
||||
prependOlder,
|
||||
trimWindow,
|
||||
windowFromPage,
|
||||
} from "./flowWindow";
|
||||
|
||||
/** A flow started at `second` past 10:00, with an id that sorts the same way */
|
||||
function flow(second: number, extra: Partial<Flow> = {}): Flow {
|
||||
const s = String(second).padStart(2, "0");
|
||||
return { id: `fl_${s}`, startedAt: `2026-10-28T10:00:${s}`, ...extra } as Flow;
|
||||
}
|
||||
|
||||
const created = (f: Flow): ModelPayload => ({
|
||||
model: f,
|
||||
change: { type: "upsert", created: true },
|
||||
});
|
||||
const updated = (f: Flow): ModelPayload => ({
|
||||
model: f,
|
||||
change: { type: "upsert", created: false },
|
||||
});
|
||||
const ids = (win: FlowWindow) => win.flows.map((f) => f.id);
|
||||
|
||||
describe("windowFromPage", () => {
|
||||
test("holds the newest page oldest first", () => {
|
||||
const win = windowFromPage([flow(3), flow(2), flow(1)], 3, 10);
|
||||
expect(ids(win)).toEqual(["fl_01", "fl_02", "fl_03"]);
|
||||
expect(win).toMatchObject({ hasOlder: true, total: 10 });
|
||||
});
|
||||
|
||||
test("knows there is nothing older when the page came back short", () => {
|
||||
expect(windowFromPage([flow(1)], 3, 1).hasOlder).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyChanges", () => {
|
||||
test("appends new traffic and counts it", () => {
|
||||
const win = applyChanges(windowFromPage([flow(2), flow(1)], 10, 2), [
|
||||
created(flow(3)),
|
||||
created(flow(4)),
|
||||
]);
|
||||
expect(ids(win)).toEqual(["fl_01", "fl_02", "fl_03", "fl_04"]);
|
||||
expect(win.total).toBe(4);
|
||||
});
|
||||
|
||||
test("puts a slow flow where it started", () => {
|
||||
const win = applyChanges(windowFromPage([flow(5), flow(1)], 10, 2), [created(flow(3))]);
|
||||
expect(ids(win)).toEqual(["fl_01", "fl_03", "fl_05"]);
|
||||
});
|
||||
|
||||
test("orders flows that started together by id", () => {
|
||||
const a = flow(2, { id: "fl_a" });
|
||||
const b = flow(2, { id: "fl_b" });
|
||||
const win = applyChanges(emptyWindow, [created(b), created(a)]);
|
||||
expect(ids(win)).toEqual(["fl_a", "fl_b"]);
|
||||
});
|
||||
|
||||
test("replaces a changed flow in place without counting it again", () => {
|
||||
const saved = flow(2, { savedRequestId: "rq_1" });
|
||||
const win = applyChanges(windowFromPage([flow(3), flow(2), flow(1)], 10, 3), [updated(saved)]);
|
||||
expect(win.flows[1]).toBe(saved);
|
||||
expect(win.total).toBe(3);
|
||||
});
|
||||
|
||||
test("replaces a flow the first page already had, without counting it again", () => {
|
||||
const win = applyChanges(windowFromPage([flow(2), flow(1)], 10, 2), [created(flow(2))]);
|
||||
expect(ids(win)).toEqual(["fl_01", "fl_02"]);
|
||||
expect(win.total).toBe(2);
|
||||
});
|
||||
|
||||
test("leaves a flow older than the window to paging, but counts it", () => {
|
||||
const win = applyChanges(windowFromPage([flow(5), flow(4)], 2, 9), [created(flow(1))]);
|
||||
expect(ids(win)).toEqual(["fl_04", "fl_05"]);
|
||||
expect(win.total).toBe(10);
|
||||
});
|
||||
|
||||
test("keeps an older flow when the window already holds everything", () => {
|
||||
const win = applyChanges(windowFromPage([flow(5), flow(4)], 10, 2), [created(flow(1))]);
|
||||
expect(ids(win)).toEqual(["fl_01", "fl_04", "fl_05"]);
|
||||
});
|
||||
|
||||
test("returns the same window for no changes", () => {
|
||||
const win = windowFromPage([flow(1)], 10, 1);
|
||||
expect(applyChanges(win, [])).toBe(win);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trimWindow", () => {
|
||||
const many = (n: number) =>
|
||||
windowFromPage(
|
||||
Array.from({ length: n }, (_, i) => flow(n - i)),
|
||||
1000,
|
||||
n,
|
||||
);
|
||||
|
||||
test("leaves the window alone within the slack", () => {
|
||||
const win = many(7);
|
||||
expect(trimWindow(win, 4, 3)).toBe(win);
|
||||
});
|
||||
|
||||
test("drops the oldest back to the size, an even number of them", () => {
|
||||
const even = trimWindow(many(8), 4, 3);
|
||||
expect(ids(even)).toEqual(["fl_05", "fl_06", "fl_07", "fl_08"]);
|
||||
expect(even).toMatchObject({ hasOlder: true, total: 8 });
|
||||
// Five over rounds up to six, so the list's stripes stay on the same flows.
|
||||
expect(ids(trimWindow(many(9), 4, 3))).toEqual(["fl_07", "fl_08", "fl_09"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prependOlder", () => {
|
||||
test("adds an older page before the oldest flow held", () => {
|
||||
const win = prependOlder(windowFromPage([flow(5), flow(4)], 2, 5), [flow(3), flow(2)], 2);
|
||||
expect(ids(win)).toEqual(["fl_02", "fl_03", "fl_04", "fl_05"]);
|
||||
expect(win.hasOlder).toBe(true);
|
||||
});
|
||||
|
||||
test("knows it reached the start when a page comes back short", () => {
|
||||
const win = prependOlder(windowFromPage([flow(5), flow(4)], 2, 3), [flow(1)], 2);
|
||||
expect(ids(win)).toEqual(["fl_01", "fl_04", "fl_05"]);
|
||||
expect(win.hasOlder).toBe(false);
|
||||
});
|
||||
|
||||
test("skips flows the window already has", () => {
|
||||
const win = prependOlder(windowFromPage([flow(5), flow(4)], 2, 5), [flow(4), flow(3)], 2);
|
||||
expect(ids(win)).toEqual(["fl_03", "fl_04", "fl_05"]);
|
||||
});
|
||||
|
||||
test("keeps a slow flow that ended while the page loaded, once its changes apply again", () => {
|
||||
const before = windowFromPage([flow(5), flow(4)], 2, 5);
|
||||
// Ended mid-request, older than the window, so left out, and missing from the page.
|
||||
const slow = created(flow(2));
|
||||
const live = applyChanges(before, [slow]);
|
||||
expect(ids(live)).toEqual(["fl_04", "fl_05"]);
|
||||
const merged = prependOlder(live, [flow(3), flow(1)], 2);
|
||||
const replayed = applyChanges(merged, [slow]);
|
||||
expect(ids(replayed)).toEqual(["fl_01", "fl_02", "fl_03", "fl_04", "fl_05"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Flow, ModelPayload } from "@yaakapp-internal/proxy-lib";
|
||||
|
||||
/** Flows the window keeps while it follows new traffic */
|
||||
export const WINDOW_SIZE = 2000;
|
||||
/**
|
||||
* Flows past `WINDOW_SIZE` before the oldest are dropped, so they go a few hundred at a time
|
||||
* rather than one per new flow, which would shift every row of the list each time.
|
||||
*/
|
||||
export const WINDOW_SLACK = 500;
|
||||
/** Older flows loaded per page on scrolling to the top */
|
||||
export const PAGE_SIZE = 500;
|
||||
|
||||
/**
|
||||
* The flows the app holds in memory: the newest in the database, oldest first, ordered by start
|
||||
* and then id as `list_flows` orders them. The database keeps every flow; older ones load back
|
||||
* in pages.
|
||||
*/
|
||||
export interface FlowWindow {
|
||||
flows: Flow[];
|
||||
/** The database has flows older than the first one here */
|
||||
hasOlder: boolean;
|
||||
/** Flows in the database */
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const emptyWindow: FlowWindow = { flows: [], hasOlder: false, total: 0 };
|
||||
|
||||
type FlowKey = Pick<Flow, "startedAt" | "id">;
|
||||
|
||||
export function compareFlows(a: FlowKey, b: FlowKey): number {
|
||||
if (a.startedAt !== b.startedAt) return a.startedAt < b.startedAt ? -1 : 1;
|
||||
if (a.id !== b.id) return a.id < b.id ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** The window from the newest page of `list_flows`, which comes newest first */
|
||||
export function windowFromPage(page: Flow[], limit: number, total: number): FlowWindow {
|
||||
return { flows: [...page].reverse(), hasOlder: page.length >= limit, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds new flows where they belong and replaces changed ones. New traffic almost always lands at
|
||||
* the end, so this is an append. A flow that started before the oldest one held, while older ones
|
||||
* are still in the database, is left for paging to load, so the window never has a gap.
|
||||
*/
|
||||
export function applyChanges(win: FlowWindow, changes: ModelPayload[]): FlowWindow {
|
||||
if (changes.length === 0) return win;
|
||||
const flows = win.flows.slice();
|
||||
let total = win.total;
|
||||
for (const { model, change } of changes) {
|
||||
const i = insertionIndex(flows, model);
|
||||
const existing = flows[i]?.id === model.id;
|
||||
if (change.type === "delete") {
|
||||
if (existing) flows.splice(i, 1);
|
||||
total = Math.max(0, total - 1);
|
||||
} else if (existing) {
|
||||
flows[i] = model;
|
||||
} else {
|
||||
if (change.created) total++;
|
||||
const first = flows[0];
|
||||
if (win.hasOlder && first != null && compareFlows(model, first) < 0) continue;
|
||||
flows.splice(i, 0, model);
|
||||
}
|
||||
}
|
||||
return { ...win, flows, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the oldest flows once there are `slack` more than `size`, back down to `size`. Always an
|
||||
* even number of them, so the list's alternating rows keep their shade.
|
||||
*/
|
||||
export function trimWindow(win: FlowWindow, size = WINDOW_SIZE, slack = WINDOW_SLACK): FlowWindow {
|
||||
if (win.flows.length <= size + slack) return win;
|
||||
let drop = win.flows.length - size;
|
||||
drop += drop % 2;
|
||||
return { ...win, flows: win.flows.slice(drop), hasOlder: true };
|
||||
}
|
||||
|
||||
/** Adds a page of older flows from `list_flows`, newest first, before the oldest one held */
|
||||
export function prependOlder(win: FlowWindow, page: Flow[], limit: number): FlowWindow {
|
||||
const first = win.flows[0];
|
||||
const older = page.filter((f) => first == null || compareFlows(f, first) < 0).reverse();
|
||||
return { ...win, flows: [...older, ...win.flows], hasOlder: page.length >= limit };
|
||||
}
|
||||
|
||||
/** Where `flow` is, or would go, in `flows` */
|
||||
function insertionIndex(flows: FlowKey[], flow: FlowKey): number {
|
||||
// New traffic goes at the end; skip the search for it.
|
||||
const last = flows[flows.length - 1];
|
||||
if (last == null || compareFlows(last, flow) < 0) return flows.length;
|
||||
let lo = 0;
|
||||
let hi = flows.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
const item = flows[mid];
|
||||
if (item != null && compareFlows(item, flow) < 0) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { countNewFlows } from "./follow";
|
||||
|
||||
const at = (...times: string[]) => times.map((t) => ({ startedAt: `2026-10-26T10:00:${t}` }));
|
||||
|
||||
describe("countNewFlows", () => {
|
||||
test("counts flows that started after the hold", () => {
|
||||
expect(countNewFlows(at("01", "02", "03", "04"), "2026-10-26T10:00:02")).toBe(2);
|
||||
});
|
||||
|
||||
test("counts every flow when there was no traffic at the hold", () => {
|
||||
expect(countNewFlows(at("01", "02"), null)).toBe(2);
|
||||
});
|
||||
|
||||
test("keeps counting after a filter hides the flow the list was held on", () => {
|
||||
// Held while the newest flow overall started at :03, then filtered to a view without it
|
||||
expect(countNewFlows(at("01", "05", "06"), "2026-10-26T10:00:03")).toBe(2);
|
||||
});
|
||||
|
||||
test("counts nothing when no flow in the view is new", () => {
|
||||
expect(countNewFlows(at("01", "02"), "2026-10-26T10:00:03")).toBe(0);
|
||||
});
|
||||
|
||||
test("compares start times with and without fractional seconds", () => {
|
||||
expect(countNewFlows(at("03.5", "04"), "2026-10-26T10:00:03")).toBe(2);
|
||||
expect(countNewFlows(at("03", "03.25"), "2026-10-26T10:00:03.2")).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||
|
||||
/**
|
||||
* Flows in the list that started after the list was held. `heldAt` is the start of the newest
|
||||
* flow across all traffic at that moment, or null when there was none, so it still holds when
|
||||
* the sidebar filter changes what the list shows. Expects flows oldest first, like `flowsAtom`.
|
||||
*/
|
||||
export function countNewFlows(flows: Pick<Flow, "startedAt">[], heldAt: string | null): number {
|
||||
if (heldAt == null) return flows.length;
|
||||
let count = 0;
|
||||
for (let i = flows.length - 1; i >= 0; i--) {
|
||||
const flow = flows[i];
|
||||
if (flow == null || flow.startedAt <= heldAt) break;
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -1,14 +1,44 @@
|
||||
import type { ActionInvocation, ActionMetadata } from "@yaakapp-internal/proxy-lib";
|
||||
import type { ActionInvocation, GlobalAction } from "@yaakapp-internal/proxy-lib";
|
||||
import type { HotkeyContextValue, HotKeyOptions } from "@yaakapp-internal/ui";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||
import { rpc } from "./rpc";
|
||||
import { getOsType } from "./tauri";
|
||||
|
||||
type ActionBinding = {
|
||||
invocation: ActionInvocation;
|
||||
meta: ActionMetadata;
|
||||
keys: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean };
|
||||
};
|
||||
declare module "@yaakapp-internal/ui" {
|
||||
interface HotkeyActionRegistry {
|
||||
action: GlobalAction;
|
||||
}
|
||||
}
|
||||
|
||||
type Keys = { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean };
|
||||
|
||||
type Binding = { invocation: ActionInvocation; hotkey: string; keys: Keys };
|
||||
|
||||
interface Callback {
|
||||
action: GlobalAction;
|
||||
callback: (e: KeyboardEvent) => void;
|
||||
options: HotKeyOptions;
|
||||
}
|
||||
|
||||
let callbacks: Callback[] = [];
|
||||
|
||||
function register(
|
||||
action: GlobalAction,
|
||||
callback: (e: KeyboardEvent) => void,
|
||||
options: HotKeyOptions,
|
||||
) {
|
||||
callbacks = [
|
||||
...callbacks.filter((cb) => cb.action !== action || cb.options.priority !== options.priority),
|
||||
{ action, callback, options },
|
||||
].sort((a, b) => (b.options.priority ?? 0) - (a.options.priority ?? 0));
|
||||
return () => {
|
||||
callbacks = callbacks.filter((cb) => cb.callback !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse a hotkey string like "Ctrl+Shift+P" into its parts. */
|
||||
function parseHotkey(hotkey: string): ActionBinding["keys"] {
|
||||
function parseHotkey(hotkey: string): Keys {
|
||||
const parts = hotkey.split("+").map((p) => p.trim().toLowerCase());
|
||||
return {
|
||||
ctrl: parts.includes("ctrl") || parts.includes("control"),
|
||||
@@ -22,42 +52,64 @@ function parseHotkey(hotkey: string): ActionBinding["keys"] {
|
||||
};
|
||||
}
|
||||
|
||||
function matchesEvent(binding: ActionBinding["keys"], e: KeyboardEvent): boolean {
|
||||
function matchesEvent(keys: Keys, e: KeyboardEvent): boolean {
|
||||
return (
|
||||
e.ctrlKey === binding.ctrl &&
|
||||
e.shiftKey === binding.shift &&
|
||||
e.altKey === binding.alt &&
|
||||
e.metaKey === binding.meta &&
|
||||
e.key.toLowerCase() === binding.key
|
||||
e.ctrlKey === keys.ctrl &&
|
||||
e.shiftKey === keys.shift &&
|
||||
e.altKey === keys.alt &&
|
||||
e.metaKey === keys.meta &&
|
||||
e.key.toLowerCase() === keys.key
|
||||
);
|
||||
}
|
||||
|
||||
/** Fetch all actions from Rust and register a global keydown listener. */
|
||||
export async function initHotkeys(): Promise<() => void> {
|
||||
const { actions } = await rpc("list_actions", {});
|
||||
/**
|
||||
* Hotkeys from the actions' metadata: a key runs the component bound to its action if one is,
|
||||
* and the action itself otherwise.
|
||||
*/
|
||||
export function useHotkeyContextValue(): HotkeyContextValue {
|
||||
const { data } = useRpcQuery("list_actions", {});
|
||||
|
||||
const bindings: ActionBinding[] = actions
|
||||
.filter(
|
||||
// oxlint-disable-next-line no-redundant-type-constituents -- ActionMetadata resolves at runtime
|
||||
(entry): entry is [ActionInvocation, ActionMetadata & { defaultHotkey: string }] =>
|
||||
entry[1].defaultHotkey != null,
|
||||
)
|
||||
.map(([invocation, meta]) => ({
|
||||
invocation,
|
||||
meta,
|
||||
keys: parseHotkey(meta.defaultHotkey),
|
||||
}));
|
||||
const bindings = useMemo(() => {
|
||||
const map = new Map<GlobalAction, Binding>();
|
||||
for (const [invocation, meta] of data?.actions ?? []) {
|
||||
if (meta.defaultHotkey == null) continue;
|
||||
const hotkey = meta.defaultHotkey.replace(/\bCtrl\b/, "Control");
|
||||
map.set(invocation.action, { invocation, hotkey, keys: parseHotkey(hotkey) });
|
||||
}
|
||||
return map;
|
||||
}, [data]);
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
for (const binding of bindings) {
|
||||
if (matchesEvent(binding.keys, e)) {
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
for (const { action, callback, options } of callbacks) {
|
||||
const enable = typeof options.enable === "function" ? options.enable() : options.enable;
|
||||
const binding = bindings.get(action);
|
||||
if (enable === false || binding == null || !matchesEvent(binding.keys, e)) continue;
|
||||
if (!options.allowDefault) e.preventDefault();
|
||||
callback(e);
|
||||
return;
|
||||
}
|
||||
for (const binding of bindings.values()) {
|
||||
if (!matchesEvent(binding.keys, e)) continue;
|
||||
e.preventDefault();
|
||||
void rpc("execute_action", binding.invocation);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [bindings]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
os: getOsType(),
|
||||
bindingFor: (action) => {
|
||||
const hotkey = bindings.get(action)?.hotkey;
|
||||
return hotkey == null ? null : [hotkey];
|
||||
},
|
||||
register,
|
||||
}),
|
||||
[bindings],
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,13 @@ import { command, subscribe } from "./tauri";
|
||||
export type Req<K extends keyof RpcSchema> = RpcSchema[K][0];
|
||||
export type Res<K extends keyof RpcSchema> = RpcSchema[K][1];
|
||||
|
||||
/** Call a backend command. A failure rejects with an `Error`, never the bare string Tauri gives. */
|
||||
export async function rpc<K extends keyof RpcSchema>(cmd: K, payload: Req<K>): Promise<Res<K>> {
|
||||
return command<Res<K>>("rpc", { cmd, payload });
|
||||
try {
|
||||
return await command<Res<K>>("rpc", { cmd, payload });
|
||||
} catch (err) {
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to a backend event. Returns an unsubscribe function. */
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { matchesQuery, searchTerms, selectionQuery, sidebarPath } from "./search";
|
||||
|
||||
function flow(url: string, extra: Partial<Flow> = {}): Flow {
|
||||
return {
|
||||
url,
|
||||
method: "GET",
|
||||
status: 200,
|
||||
contentType: null,
|
||||
clientBundleId: null,
|
||||
clientPath: null,
|
||||
...extra,
|
||||
} as Flow;
|
||||
}
|
||||
|
||||
// The same flows and terms as `search_matches_url_method_status_and_content_type` in
|
||||
// `flows.rs`, so the live matcher and the database agree.
|
||||
const stripe = flow("https://api.stripe.com/v1/charges?customer=cus_9", {
|
||||
method: "POST",
|
||||
contentType: "application/json; charset=utf-8",
|
||||
});
|
||||
const github = flow("https://api.github.com/repos/yaak", {
|
||||
status: 404,
|
||||
contentType: "application/json; charset=utf-8",
|
||||
});
|
||||
const logo = flow("https://images.ctfassets.net/logo.png", { contentType: "image/png" });
|
||||
const health = flow("http://localhost:3000/health", { method: "HEAD", status: 503 });
|
||||
const all = [stripe, github, logo, health];
|
||||
|
||||
const search = (q: string) => all.filter((f) => matchesQuery(f, { q }));
|
||||
|
||||
describe("matchesQuery", () => {
|
||||
test("matches the host, the path and the query, any case", () => {
|
||||
expect(search("STRIPE")).toEqual([stripe]);
|
||||
expect(search("/repos/")).toEqual([github]);
|
||||
expect(search("cus_9")).toEqual([stripe]);
|
||||
});
|
||||
|
||||
test("matches the whole method", () => {
|
||||
expect(search("head")).toEqual([health]);
|
||||
expect(search("ead")).toEqual([]);
|
||||
});
|
||||
|
||||
test("matches a status, its start, or its class", () => {
|
||||
expect(search("404")).toEqual([github]);
|
||||
expect(search("50")).toEqual([health]);
|
||||
expect(search("2xx")).toEqual([stripe, logo]);
|
||||
});
|
||||
|
||||
test("matches the content type", () => {
|
||||
expect(search("png")).toEqual([logo]);
|
||||
expect(search("json")).toEqual([stripe, github]);
|
||||
});
|
||||
|
||||
test("needs every term to match", () => {
|
||||
expect(search("json get")).toEqual([github]);
|
||||
expect(search(" json 2xx ")).toEqual([stripe]);
|
||||
expect(search("json 5xx")).toEqual([]);
|
||||
expect(search("")).toEqual(all);
|
||||
});
|
||||
|
||||
test("keeps flows under a host or path, as the sidebar places them", () => {
|
||||
const under = (node: string, url: string) => matchesQuery(flow(url), { under: [node] });
|
||||
expect(under("api.stripe.com", "https://api.stripe.com/v1/charges")).toBe(true);
|
||||
expect(under("api.stripe.com", "http://api.stripe.com")).toBe(true);
|
||||
// Another port is a host node of its own, as in the sidebar.
|
||||
expect(under("api.stripe.com", "https://api.stripe.com:8443/v1")).toBe(false);
|
||||
// Empty segments and default ports are left out, as the sidebar leaves them.
|
||||
expect(under("example.com/v1/users", "https://example.com/v1//users")).toBe(true);
|
||||
expect(under("x.com/a", "http://x.com:80/a")).toBe(true);
|
||||
expect(under("api.stripe.com", "https://api.stripe.community/")).toBe(false);
|
||||
expect(under("api.stripe.com/v1/charges", "https://api.stripe.com/v1/charges/ch_1?x")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(under("api.stripe.com/v1/charges", "https://api.stripe.com/v1/chargesx")).toBe(false);
|
||||
expect(under("api.stripe.com:8443", "https://api.stripe.com/v1")).toBe(false);
|
||||
// The sidebar lowercases hosts; paths keep their case.
|
||||
expect(under("example.com/A", "http://EXAMPLE.com/A/b")).toBe(true);
|
||||
expect(under("example.com/a", "http://example.com/A")).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps flows from an app, by bundle id or path", () => {
|
||||
const chrome = flow("https://example.com/", {
|
||||
clientBundleId: "com.google.Chrome",
|
||||
clientPath: "/Applications/Google Chrome.app",
|
||||
});
|
||||
const curl = flow("https://example.com/", { clientPath: "/usr/bin/curl" });
|
||||
const unknown = flow("https://example.com/");
|
||||
const from = (app: string[]) => [chrome, curl, unknown].filter((f) => matchesQuery(f, { app }));
|
||||
expect(from(["com.google.Chrome"])).toEqual([chrome]);
|
||||
expect(from(["/usr/bin/curl"])).toEqual([curl]);
|
||||
expect(from(["/Applications/Google Chrome.app"])).toEqual([]);
|
||||
});
|
||||
|
||||
test("combines terms with the sidebar's selection", () => {
|
||||
expect(matchesQuery(stripe, { q: "post", under: ["api.stripe.com"] })).toBe(true);
|
||||
expect(matchesQuery(stripe, { q: "post", under: ["api.github.com"] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sidebarPath", () => {
|
||||
// The cases `sidebar_path_rules` in `flows.rs` checks, so the two agree.
|
||||
test("is the lowercased host without its default port, then the non-empty segments", () => {
|
||||
for (const [url, path] of [
|
||||
["https://example.com/v1//users", "example.com/v1/users"],
|
||||
["http://x.com:80/a", "x.com/a"],
|
||||
["https://x.com:443/a?q=1#f", "x.com/a"],
|
||||
["http://x.com:443/a", "x.com:443/a"],
|
||||
["https://API.Stripe.com:8443/", "api.stripe.com:8443"],
|
||||
["https://api.stripe.com", "api.stripe.com"],
|
||||
["https://api.stripe.com?x=/y", "api.stripe.com"],
|
||||
["http://[::1]:80/a/B", "[::1]/a/B"],
|
||||
["/api", "/api"],
|
||||
]) {
|
||||
expect(sidebarPath(url ?? ""), url).toBe(path);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectionQuery", () => {
|
||||
test("is null for nothing selected or a node holding every flow", () => {
|
||||
expect(selectionQuery([])).toBeNull();
|
||||
expect(selectionQuery(["root"])).toBeNull();
|
||||
expect(selectionQuery(["domain:a.com", "domains"])).toBeNull();
|
||||
expect(selectionQuery(["apps"])).toBeNull();
|
||||
});
|
||||
|
||||
test("splits hosts and paths from apps", () => {
|
||||
expect(selectionQuery(["domain:a.com", "path:b.com/v1", "app:com.apple.Safari"])).toEqual({
|
||||
under: ["a.com", "b.com/v1"],
|
||||
app: ["com.apple.Safari"],
|
||||
});
|
||||
expect(selectionQuery(["app:/usr/bin/curl"])).toEqual({ app: ["/usr/bin/curl"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchTerms", () => {
|
||||
test("trims and collapses the box's text, or is null without any", () => {
|
||||
expect(searchTerms(" stripe post ")).toBe("stripe post");
|
||||
expect(searchTerms(" ")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Flow, FlowFilter } from "@yaakapp-internal/proxy-lib";
|
||||
|
||||
/**
|
||||
* A search over every flow in the database: the search box's terms and the sidebar's selection,
|
||||
* as `list_flows` takes them.
|
||||
*/
|
||||
export type FlowQuery = Pick<FlowFilter, "q" | "under" | "app">;
|
||||
|
||||
/** The sidebar node for all of an app's flows */
|
||||
export function appNodeId(key: string): string {
|
||||
return `app:${key}`;
|
||||
}
|
||||
|
||||
/** What groups an app's flows: its bundle id, or its path for a process outside an app */
|
||||
export function appKeyOf(flow: Flow): string | null {
|
||||
return flow.clientBundleId ?? flow.clientPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar's selection as a query: hosts and paths under `under`, apps under `app`. Null when
|
||||
* the selection holds every flow.
|
||||
*/
|
||||
export function selectionQuery(selectedIds: string[]): FlowQuery | null {
|
||||
const under: string[] = [];
|
||||
const app: string[] = [];
|
||||
for (const id of selectedIds) {
|
||||
if (id === "root" || id === "domains" || id === "apps") return null;
|
||||
if (id.startsWith("domain:")) under.push(id.slice("domain:".length));
|
||||
else if (id.startsWith("path:")) under.push(id.slice("path:".length));
|
||||
else if (id.startsWith("app:")) app.push(id.slice("app:".length));
|
||||
}
|
||||
if (under.length === 0 && app.length === 0) return null;
|
||||
return {
|
||||
...(under.length > 0 ? { under } : {}),
|
||||
...(app.length > 0 ? { app } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `flow` is one `query` covers, as `list_flows` decides it in SQL, so new traffic joins a
|
||||
* search as it arrives. Every term must match the URL, the method, the status or the content type.
|
||||
*/
|
||||
export function matchesQuery(flow: Flow, query: FlowQuery): boolean {
|
||||
for (const term of (query.q ?? "").toLowerCase().split(/\s+/)) {
|
||||
if (term === "") continue;
|
||||
const matched =
|
||||
flow.url.toLowerCase().includes(term) ||
|
||||
flow.method.toLowerCase() === term ||
|
||||
(flow.contentType?.toLowerCase().includes(term) ?? false) ||
|
||||
matchesStatus(flow.status, term);
|
||||
if (!matched) return false;
|
||||
}
|
||||
if (query.under != null && !query.under.some((node) => isUnder(flow.url, node))) return false;
|
||||
if (query.app != null) {
|
||||
const key = appKeyOf(flow);
|
||||
if (key == null || !query.app.includes(key)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `404` exactly, `40` as the start of one, or a class such as `4xx` */
|
||||
function matchesStatus(status: number | null, term: string): boolean {
|
||||
if (status == null) return false;
|
||||
const klass = /^([1-9])xx$/.exec(term);
|
||||
if (klass != null) return Math.trunc(status / 100) === Number(klass[1]);
|
||||
if (!/^\d{1,3}$/.test(term)) return false;
|
||||
return term.length === 3 ? status === Number(term) : String(status).startsWith(term);
|
||||
}
|
||||
|
||||
/** The URL is the host or path `node` names, or something below it, by the sidebar's rules */
|
||||
function isUnder(url: string, node: string): boolean {
|
||||
const path = sidebarPath(url);
|
||||
const want = normalizeNode(node);
|
||||
return path === want || path.startsWith(`${want}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the sidebar places a URL: its host, lowercased and without its scheme's default port,
|
||||
* then its path's non-empty segments, joined by `/`, as in `api.stripe.com/v1/charges`. A URL
|
||||
* without a scheme is a host of its own. `sidebar_path` in `crates-proxy/yaak-proxy-lib`, which
|
||||
* the database filters by, follows the same rules.
|
||||
*/
|
||||
export function sidebarPath(url: string): string {
|
||||
const schemeEnd = url.indexOf("://");
|
||||
if (schemeEnd === -1) return url;
|
||||
const scheme = url.slice(0, schemeEnd).toLowerCase();
|
||||
const rest = url.slice(schemeEnd + 3);
|
||||
const authorityEnd = rest.search(/[/?#]/);
|
||||
let host = (authorityEnd === -1 ? rest : rest.slice(0, authorityEnd)).toLowerCase();
|
||||
const defaultPort =
|
||||
scheme === "http" || scheme === "ws"
|
||||
? ":80"
|
||||
: scheme === "https" || scheme === "wss"
|
||||
? ":443"
|
||||
: null;
|
||||
if (defaultPort != null && host.endsWith(defaultPort)) {
|
||||
host = host.slice(0, -defaultPort.length);
|
||||
}
|
||||
const path = authorityEnd === -1 ? "" : (rest.slice(authorityEnd).split(/[?#]/)[0] ?? "");
|
||||
const segments = path.split("/").filter((s) => s !== "");
|
||||
return [host, ...segments].join("/");
|
||||
}
|
||||
|
||||
/** A sidebar node as `sidebarPath` writes it, its host lowercased */
|
||||
function normalizeNode(node: string): string {
|
||||
const slash = node.indexOf("/");
|
||||
return slash === -1
|
||||
? node.toLowerCase()
|
||||
: `${node.slice(0, slash).toLowerCase()}${node.slice(slash)}`;
|
||||
}
|
||||
|
||||
/** The search box's text as terms for `list_flows`, or null when it has none */
|
||||
export function searchTerms(text: string): string | null {
|
||||
const q = text.trim().replace(/\s+/g, " ");
|
||||
return q === "" ? null : q;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { TreeNode } from "@yaakapp-internal/ui";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { applyCollapsed, collapsedMap, folderIds } from "./sidebarCollapse";
|
||||
|
||||
/** A node with a children list, empty or not, as the sidebar builds every node */
|
||||
function node(id: string, children: TreeNode<{ id: string }>[] = []): TreeNode<{ id: string }> {
|
||||
return { item: { id }, parent: null, depth: 0, children };
|
||||
}
|
||||
|
||||
const tree = node("root", [
|
||||
node("domains", [
|
||||
node("domain:api.example.com", [
|
||||
node("path:api.example.com/v1", [node("path:api.example.com/v1/users")]),
|
||||
]),
|
||||
node("domain:cdn.example.com", [node("path:cdn.example.com/app.js")]),
|
||||
node("domain:empty.example.com"),
|
||||
]),
|
||||
]);
|
||||
|
||||
describe("collapsedMap", () => {
|
||||
test("collapses every domain and path, and leaves the root and Domains open", () => {
|
||||
const map = collapsedMap(folderIds(tree), {}, {});
|
||||
// Empty ones too: the tree collapses one before the left arrow moves to its parent.
|
||||
expect(map).toEqual({
|
||||
"domain:api.example.com": true,
|
||||
"path:api.example.com/v1": true,
|
||||
"path:api.example.com/v1/users": true,
|
||||
"domain:cdn.example.com": true,
|
||||
"path:cdn.example.com/app.js": true,
|
||||
"domain:empty.example.com": true,
|
||||
});
|
||||
expect(map.root).toBeUndefined();
|
||||
expect(map.domains).toBeUndefined();
|
||||
});
|
||||
|
||||
test("opens what the user expanded, and a host seen later starts collapsed", () => {
|
||||
const expanded = { "domain:api.example.com": true };
|
||||
expect(collapsedMap(folderIds(tree), expanded, {})).toMatchObject({
|
||||
"domain:api.example.com": false,
|
||||
"path:api.example.com/v1": true,
|
||||
"domain:cdn.example.com": true,
|
||||
});
|
||||
const later = node("root", [
|
||||
node("domains", [node("domain:new.example.com", [node("path:new.example.com/x")])]),
|
||||
]);
|
||||
expect(collapsedMap(folderIds(later), expanded, {})["domain:new.example.com"]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCollapsed", () => {
|
||||
test("keeps an expanded domain, and forgets it when collapsed again", () => {
|
||||
const prev = collapsedMap(folderIds(tree), {}, {});
|
||||
const opened = applyCollapsed(prev, { ...prev, "domain:api.example.com": false }, {}, {});
|
||||
expect(opened.expanded).toEqual({ "domain:api.example.com": true });
|
||||
|
||||
// Persisted, so the next launch builds the map from it.
|
||||
const relaunched = collapsedMap(folderIds(tree), opened.expanded, {});
|
||||
expect(relaunched["domain:api.example.com"]).toBe(false);
|
||||
|
||||
const closed = applyCollapsed(
|
||||
relaunched,
|
||||
{ ...relaunched, "domain:api.example.com": true },
|
||||
opened.expanded,
|
||||
{},
|
||||
);
|
||||
expect(closed.expanded).toEqual({});
|
||||
});
|
||||
|
||||
test("keeps the other nodes' flags apart from the persisted ones", () => {
|
||||
const prev = collapsedMap(folderIds(tree), {}, {});
|
||||
const next = applyCollapsed(prev, { ...prev, domains: true }, {}, {});
|
||||
expect(next).toEqual({ expanded: {}, others: { domains: true } });
|
||||
expect(collapsedMap(folderIds(tree), next.expanded, next.others).domains).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { TreeNode } from "@yaakapp-internal/ui";
|
||||
|
||||
/** Flags by node id, as the tree reads them: true is collapsed, and a missing id is open */
|
||||
export type CollapsedMap = Record<string, boolean>;
|
||||
|
||||
/** Domains and paths start collapsed; the rest, the Domains folder, starts open as before */
|
||||
export function startsCollapsed(id: string): boolean {
|
||||
return id.startsWith("domain:") || id.startsWith("path:");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids of the nodes the tree treats as folders, those with a children list, empty or not. The
|
||||
* tree collapses an empty one before moving to its parent on the left arrow, so it needs a flag.
|
||||
*/
|
||||
export function folderIds(root: TreeNode<{ id: string }>): string[] {
|
||||
const ids: string[] = [];
|
||||
const walk = (node: TreeNode<{ id: string }>) => {
|
||||
if (node.children == null) return;
|
||||
ids.push(node.item.id);
|
||||
for (const child of node.children ?? []) walk(child);
|
||||
};
|
||||
walk(root);
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tree's collapsed flags: every domain and path collapsed unless the user expanded it, and
|
||||
* other nodes as the user left them.
|
||||
*/
|
||||
export function collapsedMap(
|
||||
folders: string[],
|
||||
expanded: Record<string, boolean>,
|
||||
others: CollapsedMap,
|
||||
): CollapsedMap {
|
||||
const map = { ...others };
|
||||
for (const id of folders) {
|
||||
if (startsCollapsed(id)) map[id] = !expanded[id];
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the flags the tree sets into the domains and paths now expanded, which are kept, and
|
||||
* the other nodes' flags. Only the nodes whose flag changed from `prev` count.
|
||||
*/
|
||||
export function applyCollapsed(
|
||||
prev: CollapsedMap,
|
||||
next: CollapsedMap,
|
||||
expanded: Record<string, boolean>,
|
||||
others: CollapsedMap,
|
||||
): { expanded: Record<string, boolean>; others: CollapsedMap } {
|
||||
const nextExpanded = { ...expanded };
|
||||
const nextOthers = { ...others };
|
||||
for (const id of new Set([...Object.keys(prev), ...Object.keys(next)])) {
|
||||
const collapsed = next[id] === true;
|
||||
if (collapsed === (prev[id] === true)) continue;
|
||||
if (!startsCollapsed(id)) nextOthers[id] = collapsed;
|
||||
else if (collapsed) delete nextExpanded[id];
|
||||
else nextExpanded[id] = true;
|
||||
}
|
||||
return { expanded: nextExpanded, others: nextOthers };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
const reasons: Record<number, string> = {
|
||||
100: "Continue",
|
||||
101: "Switching Protocols",
|
||||
200: "OK",
|
||||
201: "Created",
|
||||
202: "Accepted",
|
||||
204: "No Content",
|
||||
206: "Partial Content",
|
||||
301: "Moved Permanently",
|
||||
302: "Found",
|
||||
303: "See Other",
|
||||
304: "Not Modified",
|
||||
307: "Temporary Redirect",
|
||||
308: "Permanent Redirect",
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
402: "Payment Required",
|
||||
403: "Forbidden",
|
||||
404: "Not Found",
|
||||
405: "Method Not Allowed",
|
||||
406: "Not Acceptable",
|
||||
408: "Request Timeout",
|
||||
409: "Conflict",
|
||||
410: "Gone",
|
||||
412: "Precondition Failed",
|
||||
413: "Content Too Large",
|
||||
415: "Unsupported Media Type",
|
||||
418: "I'm a teapot",
|
||||
422: "Unprocessable Content",
|
||||
429: "Too Many Requests",
|
||||
500: "Internal Server Error",
|
||||
501: "Not Implemented",
|
||||
502: "Bad Gateway",
|
||||
503: "Service Unavailable",
|
||||
504: "Gateway Timeout",
|
||||
};
|
||||
|
||||
/** The engine records the status code only, so the reason is the standard one */
|
||||
export function statusReason(status: number): string | null {
|
||||
return reasons[status] ?? null;
|
||||
}
|
||||
@@ -1,11 +1,356 @@
|
||||
import { createModelStore } from "@yaakapp-internal/model-store";
|
||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||
import type { GetProxyStateResponse, ModelPayload } from "@yaakapp-internal/proxy-lib";
|
||||
import { selectedIdsFamily } from "@yaakapp-internal/ui";
|
||||
import { atom, type createStore } from "jotai";
|
||||
import { followingAtom, SIDEBAR_TREE_ID, selectedFlowAtom } from "./atoms";
|
||||
import {
|
||||
applyChanges,
|
||||
emptyWindow,
|
||||
type FlowWindow,
|
||||
PAGE_SIZE,
|
||||
prependOlder,
|
||||
trimWindow,
|
||||
WINDOW_SIZE,
|
||||
windowFromPage,
|
||||
} from "./flowWindow";
|
||||
import { listen, rpc } from "./rpc";
|
||||
import { type FlowQuery, matchesQuery, selectionQuery } from "./search";
|
||||
|
||||
type ProxyModels = {
|
||||
flow: Flow;
|
||||
};
|
||||
type Store = ReturnType<typeof createStore>;
|
||||
|
||||
export const { dataAtom, applyChange, replaceAll, listAtom, orderedListAtom } =
|
||||
createModelStore<ProxyModels>(["flow"]);
|
||||
/** How long new flows gather before they reach the list, so a burst renders once */
|
||||
const FLUSH_MS = 100;
|
||||
/** How long after new flows the total is counted again from the database */
|
||||
const RECOUNT_MS = 1000;
|
||||
/** How often the database is read for new flows while another process owns the engine */
|
||||
const POLL_MS = 1000;
|
||||
/** How often the engine's owner is checked while it is another process */
|
||||
const OWNER_POLL_MS = 2000;
|
||||
|
||||
export const flowsAtom = orderedListAtom("flow", "startedAt", "desc");
|
||||
/** The flows in memory: a window onto the database, which keeps them all */
|
||||
export const flowWindowAtom = atom<FlowWindow>(emptyWindow);
|
||||
|
||||
/** Oldest first, so new traffic arrives at the end of the list */
|
||||
export const flowsAtom = atom((get) => get(flowWindowAtom).flows);
|
||||
|
||||
/** Start of the newest flow held, across all traffic */
|
||||
export const newestStartedAtAtom = atom((get) => get(flowsAtom).at(-1)?.startedAt ?? null);
|
||||
|
||||
/** Flows in the database */
|
||||
export const flowTotalAtom = atom((get) => get(flowWindowAtom).total);
|
||||
|
||||
/** The first page of flows has loaded, so an empty window means an empty database */
|
||||
export const flowsLoadedAtom = atom(false);
|
||||
|
||||
/**
|
||||
* The database has no flows and nothing narrows the list, so the first-run card shows. A boolean
|
||||
* of its own, so new traffic does not re-render what reads it.
|
||||
*/
|
||||
export const showFirstRunAtom = atom(
|
||||
(get) => get(flowsLoadedAtom) && get(flowTotalAtom) === 0 && get(activeQueryAtom) == null,
|
||||
);
|
||||
|
||||
/** Some flow held names the app that sent it, which only some platforms can tell */
|
||||
export const hasAppsAtom = atom((get) => get(flowsAtom).some((f) => f.clientName != null));
|
||||
|
||||
/** The search box's terms, once typing settles, or null */
|
||||
export const searchTermsAtom = atom<string | null>(null);
|
||||
|
||||
/** The search the list shows, from the search box and the sidebar, or null for all traffic */
|
||||
export const activeQueryAtom = atom<FlowQuery | null>((get) => {
|
||||
const q = get(searchTermsAtom);
|
||||
const selection = selectionQuery(get(selectedIdsFamily(SIDEBAR_TREE_ID)));
|
||||
if (q == null && selection == null) return null;
|
||||
return { ...(q == null ? {} : { q }), ...selection };
|
||||
});
|
||||
|
||||
/** A window onto the flows a search covers, loaded from the database as the main one is */
|
||||
export interface SearchWindow {
|
||||
key: string;
|
||||
query: FlowQuery;
|
||||
win: FlowWindow;
|
||||
/** The first page has not arrived yet */
|
||||
loading: boolean;
|
||||
/** Changes that came while the first page loaded, applied once it is in */
|
||||
buffered: ModelPayload[];
|
||||
}
|
||||
|
||||
export const searchWindowAtom = atom<SearchWindow | null>(null);
|
||||
|
||||
/** The window the list shows: the search's, or all traffic's */
|
||||
const shownWindowAtom = atom((get) => get(searchWindowAtom)?.win ?? get(flowWindowAtom));
|
||||
|
||||
/** The flows the list shows, oldest first */
|
||||
export const shownFlowsAtom = atom((get) => get(shownWindowAtom).flows);
|
||||
|
||||
/** Flows in the database the list covers: every one, or those the search matches */
|
||||
export const shownTotalAtom = atom((get) => get(shownWindowAtom).total);
|
||||
|
||||
/** Start and id of the oldest flow shown, which loading older flows moves back */
|
||||
export const oldestFlowAtom = atom((get) => get(shownFlowsAtom)[0] ?? null);
|
||||
|
||||
/** Follows new traffic, dropping the older flows scrolling up loaded, or holds the list */
|
||||
export const followAtom = atom(
|
||||
(get) => get(followingAtom),
|
||||
(_get, set, following: boolean) => {
|
||||
set(followingAtom, following);
|
||||
if (!following) return;
|
||||
set(flowWindowAtom, (win) => trimWindow(win));
|
||||
set(searchWindowAtom, (s) => (s == null ? s : { ...s, win: trimWindow(s.win) }));
|
||||
},
|
||||
);
|
||||
|
||||
/** The engine's state, kept current from its events, and polled while another process has it */
|
||||
export const proxyStateAtom = atom<GetProxyStateResponse | null>(null);
|
||||
|
||||
/**
|
||||
* Changes applied while a page of older flows loads, or null when none is loading. A slow flow
|
||||
* that ends meanwhile may be older than the window, so left out of it, and missing from a page
|
||||
* read before it was written; applying these again once the page is in keeps it.
|
||||
*/
|
||||
let changesDuringPage: ModelPayload[] | null = null;
|
||||
|
||||
/** Loads the page of flows before the oldest one shown, from the search's flows when searching */
|
||||
export const loadOlderFlowsAtom = atom(null, async (get, set) => {
|
||||
const search = get(searchWindowAtom);
|
||||
const { flows, hasOlder } = search?.win ?? get(flowWindowAtom);
|
||||
const first = flows[0];
|
||||
if (changesDuringPage != null || !hasOlder || first == null) return;
|
||||
const changes: ModelPayload[] = [];
|
||||
changesDuringPage = changes;
|
||||
try {
|
||||
const res = await rpc("list_flows", { ...search?.query, before: first.id, limit: PAGE_SIZE });
|
||||
// Dropped when the window moved on meanwhile, trimmed, cleared or searched again.
|
||||
const merge = (win: FlowWindow) => {
|
||||
if (win.flows[0]?.id !== first.id) return win;
|
||||
const merged = prependOlder(win, res.flows, PAGE_SIZE);
|
||||
const kept = search == null ? changes : changesFor(search.query, changes);
|
||||
// Counted when they first applied.
|
||||
return { ...applyChanges(merged, kept), total: merged.total };
|
||||
};
|
||||
if (search == null) {
|
||||
set(flowWindowAtom, merge);
|
||||
} else {
|
||||
set(searchWindowAtom, (s) => (s?.key === search.key ? { ...s, win: merge(s.win) } : s));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to load older flows", err);
|
||||
} finally {
|
||||
changesDuringPage = null;
|
||||
}
|
||||
});
|
||||
|
||||
/** The changes a search's window takes: flows it matches, and every deletion */
|
||||
function changesFor(query: FlowQuery, changes: ModelPayload[]): ModelPayload[] {
|
||||
return changes.filter((c) => c.change.type === "delete" || matchesQuery(c.model, query));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the newest flows, then keeps them current with the engine's events, or with reads of the
|
||||
* database while another process owns the engine. Events gather for `FLUSH_MS` and apply
|
||||
* together, and the window drops its oldest flows while the list follows, so a new flow costs the
|
||||
* same however many the database holds. A search gets a window of its own, kept current the same
|
||||
* way.
|
||||
*/
|
||||
export function syncFlows(store: Store) {
|
||||
let pending: ModelPayload[] = [];
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let loaded = false;
|
||||
let loads = 0;
|
||||
let searchLoads = 0;
|
||||
/** A flow written at or before the newest the app has seen, for reading new ones after it */
|
||||
let seenId: string | null = null;
|
||||
|
||||
function flush() {
|
||||
timer = null;
|
||||
if (!loaded || pending.length === 0) return;
|
||||
const changes = pending;
|
||||
pending = [];
|
||||
changesDuringPage?.push(...changes);
|
||||
const following = store.get(followingAtom);
|
||||
store.set(flowWindowAtom, (win) => {
|
||||
const next = applyChanges(win, changes);
|
||||
return following ? trimWindow(next) : next;
|
||||
});
|
||||
store.set(searchWindowAtom, (s) => {
|
||||
if (s == null) return s;
|
||||
// A matching flow written after the page's snapshot would otherwise be missed.
|
||||
if (s.loading) return { ...s, buffered: [...s.buffered, ...changes] };
|
||||
const next = applyChanges(s.win, changesFor(s.query, changes));
|
||||
return { ...s, win: following ? trimWindow(next) : next };
|
||||
});
|
||||
const selected = store.get(selectedFlowAtom);
|
||||
const change = changes.findLast((c) => c.model.id === selected?.id);
|
||||
if (change != null) {
|
||||
store.set(selectedFlowAtom, change.change.type === "delete" ? null : change.model);
|
||||
}
|
||||
if (recountTimer == null) recountTimer = setTimeout(recount, RECOUNT_MS);
|
||||
}
|
||||
|
||||
function receive(changes: ModelPayload[]) {
|
||||
pending.push(...changes);
|
||||
if (timer == null) timer = setTimeout(flush, FLUSH_MS);
|
||||
}
|
||||
|
||||
// The total counts each new flow as it comes, which a flow both in a page and in the events
|
||||
// around it can count twice or not at all; a count from the database a moment later settles it.
|
||||
let recountTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function recount() {
|
||||
recountTimer = null;
|
||||
const thisLoad = loads;
|
||||
void rpc("list_flows", { limit: 0 }).then((res) => {
|
||||
if (thisLoad !== loads) return;
|
||||
store.set(flowWindowAtom, (win) =>
|
||||
win.total === res.total ? win : { ...win, total: res.total },
|
||||
);
|
||||
});
|
||||
const search = store.get(searchWindowAtom);
|
||||
if (search == null || search.loading) return;
|
||||
void rpc("list_flows", { ...search.query, limit: 0 }).then((res) => {
|
||||
const total = res.matching ?? res.total;
|
||||
store.set(searchWindowAtom, (s) =>
|
||||
s?.key !== search.key || s.win.total === total ? s : { ...s, win: { ...s.win, total } },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
listen("model_write", (payload) => {
|
||||
seenId = payload.model.id;
|
||||
receive([payload]);
|
||||
});
|
||||
|
||||
// Loads again rather than only emptying, for a flow written between the delete and the event.
|
||||
listen("flows_cleared", () => {
|
||||
pending = [];
|
||||
store.set(flowWindowAtom, emptyWindow);
|
||||
store.set(selectedFlowAtom, null);
|
||||
store.set(followingAtom, true);
|
||||
load();
|
||||
});
|
||||
|
||||
function load() {
|
||||
loaded = false;
|
||||
const thisLoad = ++loads;
|
||||
void rpc("list_flows", { limit: WINDOW_SIZE }).then((res) => {
|
||||
// A clear since asked for a newer page.
|
||||
if (thisLoad !== loads) return;
|
||||
store.set(flowWindowAtom, windowFromPage(res.flows, WINDOW_SIZE, res.total));
|
||||
seenId = res.flows[0]?.id ?? null;
|
||||
loaded = true;
|
||||
store.set(flowsLoadedAtom, true);
|
||||
flush();
|
||||
});
|
||||
loadSearch(store.get(activeQueryAtom));
|
||||
}
|
||||
|
||||
/** Loads the first page of `query`'s flows into a window of its own, or drops it for null */
|
||||
function loadSearch(query: FlowQuery | null) {
|
||||
if (query == null) {
|
||||
store.set(searchWindowAtom, null);
|
||||
return;
|
||||
}
|
||||
// Unique to this load, so a slower answer to an earlier one with the same query, such as
|
||||
// one read before a clear, is dropped.
|
||||
const key = `${++searchLoads}:${JSON.stringify(query)}`;
|
||||
store.set(searchWindowAtom, { key, query, win: emptyWindow, loading: true, buffered: [] });
|
||||
const settle = (page: FlowWindow | null) =>
|
||||
store.set(searchWindowAtom, (s) => {
|
||||
if (s?.key !== key) return s;
|
||||
// Applied again over the page: a flow already in it is replaced, not added.
|
||||
const win = applyChanges(page ?? s.win, changesFor(query, s.buffered));
|
||||
return {
|
||||
...s,
|
||||
win: page == null ? win : { ...win, total: page.total },
|
||||
loading: false,
|
||||
buffered: [],
|
||||
};
|
||||
});
|
||||
void rpc("list_flows", { ...query, limit: WINDOW_SIZE })
|
||||
.then((res) => settle(windowFromPage(res.flows, WINDOW_SIZE, res.matching ?? res.total)))
|
||||
.catch((err) => {
|
||||
console.warn("Failed to search flows", err);
|
||||
settle(null);
|
||||
});
|
||||
}
|
||||
|
||||
let activeKey = JSON.stringify(store.get(activeQueryAtom));
|
||||
store.sub(activeQueryAtom, () => {
|
||||
const query = store.get(activeQueryAtom);
|
||||
const key = JSON.stringify(query);
|
||||
if (key === activeKey) return;
|
||||
activeKey = key;
|
||||
loadSearch(query);
|
||||
});
|
||||
|
||||
// While another process owns the engine no events come, so new flows are read from the
|
||||
// database. Reading after a flow that started last can return a slow one already seen, which
|
||||
// applies as an update; it never skips one.
|
||||
let polling = false;
|
||||
function readNewFlows() {
|
||||
if (polling || !loaded) return;
|
||||
polling = true;
|
||||
const after = seenId;
|
||||
const read =
|
||||
after == null
|
||||
? rpc("list_flows", { limit: WINDOW_SIZE })
|
||||
: rpc("list_flows", { after, limit: WINDOW_SIZE });
|
||||
read
|
||||
.then((res) => {
|
||||
if (res.flows.length === 0) return;
|
||||
// More arrived than one read holds: load the newest again rather than leave a gap.
|
||||
if (res.flows.length >= WINDOW_SIZE) {
|
||||
load();
|
||||
return;
|
||||
}
|
||||
if (seenId === after) seenId = res.flows[0]?.id ?? seenId;
|
||||
const known = new Set(store.get(flowsAtom).map((f) => f.id));
|
||||
receive(
|
||||
[...res.flows].reverse().map((model) => ({
|
||||
model,
|
||||
change: { type: "upsert", created: !known.has(model.id) },
|
||||
})),
|
||||
);
|
||||
})
|
||||
// The flow read after was cleared: start again from the newest.
|
||||
.catch(() => {
|
||||
seenId = null;
|
||||
load();
|
||||
})
|
||||
.finally(() => {
|
||||
polling = false;
|
||||
});
|
||||
}
|
||||
setInterval(() => {
|
||||
if (store.get(proxyStateAtom)?.otherOwner != null) readNewFlows();
|
||||
}, POLL_MS);
|
||||
// One last read once another owner lets go, for flows it wrote after the last one.
|
||||
let hadOtherOwner = store.get(proxyStateAtom)?.otherOwner != null;
|
||||
store.sub(proxyStateAtom, () => {
|
||||
const hasOtherOwner = store.get(proxyStateAtom)?.otherOwner != null;
|
||||
if (hadOtherOwner && !hasOtherOwner) readNewFlows();
|
||||
hadOtherOwner = hasOtherOwner;
|
||||
});
|
||||
|
||||
load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps `proxyStateAtom` current: on each state change, and polled while the engine is not
|
||||
* running here, so another process taking it or letting it go shows.
|
||||
*/
|
||||
export function syncProxyState(store: Store) {
|
||||
function refresh() {
|
||||
rpc("get_proxy_state", {})
|
||||
.then((state) => store.set(proxyStateAtom, state))
|
||||
.catch((err) => console.warn("Failed to read the proxy state", err));
|
||||
}
|
||||
listen("proxy_state_changed", refresh);
|
||||
setInterval(() => {
|
||||
if (store.get(proxyStateAtom)?.state !== "running") refresh();
|
||||
}, OWNER_POLL_MS);
|
||||
refresh();
|
||||
}
|
||||
|
||||
/** Reads the proxy state again, after an action that changes it without an event */
|
||||
export async function refreshProxyState(store: Store) {
|
||||
store.set(proxyStateAtom, await rpc("get_proxy_state", {}));
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { HelperState } from "@yaakapp-internal/proxy-lib";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
helperPollInterval,
|
||||
helperRow,
|
||||
recordingLabel,
|
||||
type SystemProxyState,
|
||||
systemProxySwitch,
|
||||
} from "./systemProxy";
|
||||
|
||||
const off: SystemProxyState = { routing: false, port: 9090, pending: null, error: null };
|
||||
|
||||
describe("systemProxySwitch", () => {
|
||||
test("says whether this Mac's traffic is routed now", () => {
|
||||
expect(systemProxySwitch({ ...off, routing: true })).toEqual({
|
||||
checked: true,
|
||||
description: "Routing this Mac's web traffic through Yaak Proxy",
|
||||
failed: false,
|
||||
});
|
||||
expect(systemProxySwitch(off)).toEqual({
|
||||
checked: false,
|
||||
description: "Not routing. Only apps pointed at port 9090 are captured",
|
||||
failed: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("shows the new value while the password prompt is up", () => {
|
||||
expect(systemProxySwitch({ ...off, pending: true })).toEqual({
|
||||
checked: true,
|
||||
description: "Waiting for your password",
|
||||
failed: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("waits for no password when the helper makes the change", () => {
|
||||
expect(systemProxySwitch({ ...off, pending: false, helper: true })).toEqual({
|
||||
checked: false,
|
||||
description: "Changing the system proxy",
|
||||
failed: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("snaps back on a cancelled prompt and says so", () => {
|
||||
expect(systemProxySwitch({ ...off, error: "Cancelled in the password dialog" })).toEqual({
|
||||
checked: false,
|
||||
description: "Cancelled in the password prompt",
|
||||
failed: true,
|
||||
});
|
||||
expect(systemProxySwitch({ ...off, error: "networksetup failed" }).description).toBe(
|
||||
"networksetup failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("helperRow", () => {
|
||||
test("offers each next step, and says which way changes go", () => {
|
||||
expect(helperRow("not_registered", "admin_prompt")).toMatchObject({
|
||||
title: "Helper: not installed",
|
||||
action: "install",
|
||||
});
|
||||
expect(helperRow("requires_approval", "admin_prompt")).toMatchObject({
|
||||
title: "Helper: awaiting approval",
|
||||
action: "approve",
|
||||
});
|
||||
const enabled = helperRow("enabled", "helper");
|
||||
expect(enabled).toMatchObject({ title: "Helper: enabled", action: "unregister" });
|
||||
expect(enabled.description).toMatch(/No password/);
|
||||
});
|
||||
|
||||
test("says when the password prompt still has settings to put back", () => {
|
||||
expect(helperRow("enabled", "admin_prompt").description).toMatch(/password prompt/);
|
||||
});
|
||||
|
||||
test("offers to install when macOS can't find it, rather than blaming a dev build", () => {
|
||||
expect(helperRow("not_found", "admin_prompt")).toMatchObject({
|
||||
title: "Helper: not found by macOS",
|
||||
action: "install",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps every row short", () => {
|
||||
const states: HelperState[] = [
|
||||
"unavailable",
|
||||
"not_found",
|
||||
"not_registered",
|
||||
"requires_approval",
|
||||
"enabled",
|
||||
];
|
||||
for (const state of states) {
|
||||
for (const backend of ["helper", "admin_prompt"] as const) {
|
||||
const row = helperRow(state, backend);
|
||||
expect(row.title.length).toBeLessThanOrEqual(30);
|
||||
expect(row.description.length).toBeLessThanOrEqual(60);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("has a titled row for every state the backend sends", () => {
|
||||
// A missing key here fails the typecheck, so a new state can't be left out.
|
||||
const states: Record<HelperState, true> = {
|
||||
unavailable: true,
|
||||
not_found: true,
|
||||
not_registered: true,
|
||||
requires_approval: true,
|
||||
enabled: true,
|
||||
};
|
||||
for (const state of Object.keys(states) as HelperState[]) {
|
||||
for (const backend of ["helper", "admin_prompt", "sudo"] as const) {
|
||||
expect(helperRow(state, backend).title).toMatch(/^Helper: \S/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("still gives a row for a state this build doesn't know", () => {
|
||||
const row = helperRow("from_the_future" as HelperState, "admin_prompt");
|
||||
expect(row).toEqual({
|
||||
title: "Helper: from_the_future",
|
||||
description: "Changes use the password prompt.",
|
||||
action: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("says plainly that a dev build cannot use it", () => {
|
||||
expect(helperRow("unavailable", "admin_prompt")).toMatchObject({
|
||||
title: "Helper: not in dev builds",
|
||||
action: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("helperPollInterval", () => {
|
||||
const now = 100_000;
|
||||
|
||||
test("polls every 2 seconds while approval is pending", () => {
|
||||
expect(helperPollInterval("requires_approval", null, now)).toBe(2000);
|
||||
});
|
||||
|
||||
test("stops once enabled or not registered, even just after registering", () => {
|
||||
expect(helperPollInterval("enabled", now - 500, now)).toBe(false);
|
||||
expect(helperPollInterval("not_registered", now - 500, now)).toBe(false);
|
||||
});
|
||||
|
||||
test("polls briefly after registering, until approval shows", () => {
|
||||
expect(helperPollInterval("not_found", now - 500, now)).toBe(2000);
|
||||
expect(helperPollInterval(undefined, now - 500, now)).toBe(2000);
|
||||
expect(helperPollInterval("not_found", now - 10_000, now)).toBe(false);
|
||||
});
|
||||
|
||||
test("never polls in other states", () => {
|
||||
for (const helper of ["unavailable", "not_found", undefined] as const) {
|
||||
expect(helperPollInterval(helper, null, now)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordingLabel", () => {
|
||||
test("offers the opposite of the current state", () => {
|
||||
expect(recordingLabel(true)).toBe("Pause Recording");
|
||||
expect(recordingLabel(false)).toBe("Resume Recording");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { HelperState, SystemProxyBackend } from "@yaakapp-internal/proxy-lib";
|
||||
|
||||
/**
|
||||
* "Override system proxy": this Mac's web traffic pointed at the running engine, changed at once
|
||||
* whenever it is flipped, through the privileged helper or the macOS password prompt. Whether it
|
||||
* turns on as the app launches is a separate setting.
|
||||
*/
|
||||
export interface SystemProxyState {
|
||||
/** This Mac's web traffic points at the engine now */
|
||||
routing: boolean;
|
||||
/** The port apps can be pointed at by hand */
|
||||
port: number | null;
|
||||
/** A change is in flight, to this value */
|
||||
pending: boolean | null;
|
||||
/** The last change failed, with this message */
|
||||
error: string | null;
|
||||
/** Changes go through the helper, with no password prompt */
|
||||
helper?: boolean;
|
||||
}
|
||||
|
||||
export interface SystemProxySwitch {
|
||||
checked: boolean;
|
||||
description: string;
|
||||
/** The description reports a failure */
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
/** What the system proxy switch shows */
|
||||
export function systemProxySwitch(state: SystemProxyState): SystemProxySwitch {
|
||||
if (state.pending != null) {
|
||||
const description = state.helper ? "Changing the system proxy" : "Waiting for your password";
|
||||
return { checked: state.pending, description, failed: false };
|
||||
}
|
||||
// A failed change leaves the switch where the system proxy actually is.
|
||||
const checked = state.routing;
|
||||
if (state.error != null) {
|
||||
const cancelled = /cancel/i.test(state.error);
|
||||
return {
|
||||
checked,
|
||||
description: cancelled ? "Cancelled in the password prompt" : state.error,
|
||||
failed: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
checked,
|
||||
description: checked
|
||||
? "Routing this Mac's web traffic through Yaak Proxy"
|
||||
: `Not routing. Only apps pointed at port ${state.port ?? 9090} are captured`,
|
||||
failed: false,
|
||||
};
|
||||
}
|
||||
|
||||
export interface HelperRow {
|
||||
title: string;
|
||||
/** Which way changes go, and why */
|
||||
description: string;
|
||||
/** What the row's button does, if it has one: the first two open the approval dialog */
|
||||
action: "install" | "approve" | "unregister" | null;
|
||||
}
|
||||
|
||||
/** The privileged helper's row in the state pill menu, kept short */
|
||||
export function helperRow(helper: HelperState, backend: SystemProxyBackend): HelperRow {
|
||||
switch (helper) {
|
||||
case "unavailable":
|
||||
return {
|
||||
title: "Helper: not in dev builds",
|
||||
description: "Changes use the password prompt.",
|
||||
action: null,
|
||||
};
|
||||
case "not_found":
|
||||
// What macOS reports until the helper is first installed.
|
||||
return {
|
||||
title: "Helper: not found by macOS",
|
||||
description: "Changes use the password prompt.",
|
||||
action: "install",
|
||||
};
|
||||
case "not_registered":
|
||||
return {
|
||||
title: "Helper: not installed",
|
||||
description: "Changes use the password prompt.",
|
||||
action: "install",
|
||||
};
|
||||
case "requires_approval":
|
||||
return {
|
||||
title: "Helper: awaiting approval",
|
||||
description: "Changes use the password prompt.",
|
||||
action: "approve",
|
||||
};
|
||||
case "enabled":
|
||||
return {
|
||||
title: "Helper: enabled",
|
||||
description:
|
||||
backend === "helper"
|
||||
? "No password prompts."
|
||||
: "One more password prompt puts back your settings.",
|
||||
action: "unregister",
|
||||
};
|
||||
default: {
|
||||
// Every state is handled above; one this build doesn't know still gets a row, rather
|
||||
// than taking the whole menu down.
|
||||
const unknown: never = helper;
|
||||
return {
|
||||
title: `Helper: ${String(unknown)}`,
|
||||
description: "Changes use the password prompt.",
|
||||
action: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How often the helper's state is read while it can change outside the app */
|
||||
export const HELPER_POLL_MS = 2000;
|
||||
|
||||
/** How long after registering the state is read even before it shows approval pending */
|
||||
export const HELPER_REGISTER_POLL_MS = 10_000;
|
||||
|
||||
/**
|
||||
* The interval to read the helper's state at: while it awaits approval in System Settings, and
|
||||
* briefly after registering, until it settles as enabled or not registered. Never otherwise.
|
||||
*/
|
||||
export function helperPollInterval(
|
||||
helper: HelperState | undefined,
|
||||
registeredAt: number | null,
|
||||
now: number,
|
||||
): number | false {
|
||||
if (helper === "requires_approval") return HELPER_POLL_MS;
|
||||
if (helper === "enabled" || helper === "not_registered") return false;
|
||||
const justRegistered = registeredAt != null && now - registeredAt < HELPER_REGISTER_POLL_MS;
|
||||
return justRegistered ? HELPER_POLL_MS : false;
|
||||
}
|
||||
|
||||
/** The menu item that pauses or resumes storing flows */
|
||||
export function recordingLabel(recording: boolean): string {
|
||||
return recording ? "Pause Recording" : "Resume Recording";
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen as tauriListen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { type as tauriOsType } from "@tauri-apps/plugin-os";
|
||||
import { unsubscribeWhenReady } from "./unsubscribe";
|
||||
|
||||
/** Call a Tauri command. */
|
||||
export function command<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
@@ -19,6 +20,16 @@ export function subscribe<T>(event: string, callback: (payload: T) => void): ()
|
||||
return () => unsub?.();
|
||||
}
|
||||
|
||||
/** Calls `callback` each time the window regains focus. Returns an unsubscribe function. */
|
||||
export function onWindowFocus(callback: () => void): () => void {
|
||||
const subscription = unsubscribeWhenReady(
|
||||
getCurrentWebviewWindow().onFocusChanged(
|
||||
({ payload: focused }) => focused && subscription.active() && callback(),
|
||||
),
|
||||
);
|
||||
return subscription.unsubscribe;
|
||||
}
|
||||
|
||||
/** Show the current webview window. */
|
||||
export function showWindow(): Promise<void> {
|
||||
return getCurrentWebviewWindow().show();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { type ShowToastArgs, showToast } from "@yaakapp-internal/ui";
|
||||
import { useStore } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
|
||||
/** Shows one of the client's toasts from this app's store, a success unless it says otherwise */
|
||||
export function useShowToast() {
|
||||
const store = useStore();
|
||||
return useCallback(
|
||||
(toast: ShowToastArgs) => showToast(store, { color: "success", ...toast }),
|
||||
[store],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test, vi } from "vite-plus/test";
|
||||
import { unsubscribeWhenReady } from "./unsubscribe";
|
||||
|
||||
function pending() {
|
||||
let resolve: (fn: () => void) => void = () => {};
|
||||
const promise = new Promise<() => void>((r) => (resolve = r));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("unsubscribeWhenReady", () => {
|
||||
test("unsubscribes once ready when cleanup ran first", async () => {
|
||||
const { promise, resolve } = pending();
|
||||
const unsub = vi.fn();
|
||||
const subscription = unsubscribeWhenReady(promise);
|
||||
subscription.unsubscribe();
|
||||
expect(subscription.active()).toBe(false);
|
||||
resolve(unsub);
|
||||
await promise;
|
||||
await Promise.resolve();
|
||||
expect(unsub).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("unsubscribes at once when already ready, and only once", async () => {
|
||||
const unsub = vi.fn();
|
||||
const promise = Promise.resolve(unsub);
|
||||
const subscription = unsubscribeWhenReady(promise);
|
||||
await promise;
|
||||
await Promise.resolve();
|
||||
expect(subscription.active()).toBe(true);
|
||||
subscription.unsubscribe();
|
||||
subscription.unsubscribe();
|
||||
expect(unsub).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* An unsubscribe for a subscription that is still being set up. Called before the setup
|
||||
* resolves, it unsubscribes as soon as it does; `active` says whether to still deliver events.
|
||||
*/
|
||||
export function unsubscribeWhenReady(subscription: Promise<() => void>): {
|
||||
unsubscribe: () => void;
|
||||
active: () => boolean;
|
||||
} {
|
||||
let unsub: (() => void) | null = null;
|
||||
let cancelled = false;
|
||||
subscription
|
||||
.then((fn) => {
|
||||
if (cancelled) fn();
|
||||
else unsub = fn;
|
||||
})
|
||||
.catch(console.error);
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
cancelled = true;
|
||||
unsub?.();
|
||||
unsub = null;
|
||||
},
|
||||
active: () => !cancelled,
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
:root {
|
||||
--font-family-interface: "";
|
||||
--font-family-editor: "";
|
||||
--editor-font-size: 12px;
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
+16
-20
@@ -1,29 +1,23 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createStore, Provider } from "jotai";
|
||||
import { LazyMotion, MotionConfig } from "motion/react";
|
||||
import { StrictMode } from "react";
|
||||
import { HotkeyProvider } from "@yaakapp-internal/ui";
|
||||
import { type ReactNode, StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ProxyLayout } from "./components/ProxyLayout";
|
||||
import { listen, rpc } from "./lib/rpc";
|
||||
import { initHotkeys } from "./lib/hotkeys";
|
||||
import { applyChange, dataAtom, replaceAll } from "./lib/store";
|
||||
import { useHotkeyContextValue } from "./lib/hotkeys";
|
||||
import { syncFlows, syncProxyState } from "./lib/store";
|
||||
import "./main.css";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const jotaiStore = createStore();
|
||||
|
||||
// Load initial models from the database
|
||||
void rpc("list_models", {}).then((res) => {
|
||||
jotaiStore.set(dataAtom, (prev) => replaceAll(prev, "flow", res.flows));
|
||||
});
|
||||
syncProxyState(jotaiStore);
|
||||
syncFlows(jotaiStore);
|
||||
|
||||
// Register hotkeys from action metadata
|
||||
void initHotkeys();
|
||||
|
||||
// Subscribe to model change events from the backend
|
||||
void listen("model_write", (payload) => {
|
||||
jotaiStore.set(dataAtom, (prev) => applyChange(prev, "flow", payload.model, payload.change));
|
||||
});
|
||||
function Hotkeys({ children }: { children: ReactNode }) {
|
||||
return <HotkeyProvider value={useHotkeyContextValue()}>{children}</HotkeyProvider>;
|
||||
}
|
||||
|
||||
const motionFeatures = () => import("framer-motion").then((mod) => mod.domAnimation);
|
||||
|
||||
@@ -31,11 +25,13 @@ createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={jotaiStore}>
|
||||
<LazyMotion strict features={motionFeatures}>
|
||||
<MotionConfig transition={{ duration: 0.1 }}>
|
||||
<ProxyLayout />
|
||||
</MotionConfig>
|
||||
</LazyMotion>
|
||||
<Hotkeys>
|
||||
<LazyMotion strict features={motionFeatures}>
|
||||
<MotionConfig transition={{ duration: 0.1 }}>
|
||||
<ProxyLayout />
|
||||
</MotionConfig>
|
||||
</LazyMotion>
|
||||
</Hotkeys>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module "vkbeautify";
|
||||
@@ -10,18 +10,20 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.104.2",
|
||||
"@tanstack/react-virtual": "^3.14.17",
|
||||
"@tauri-apps/api": "^2.12.3",
|
||||
"@tauri-apps/plugin-dialog": "^2.8.2",
|
||||
"@tauri-apps/plugin-opener": "^2.7.1",
|
||||
"@tauri-apps/plugin-os": "^2.4.0",
|
||||
"@yaakapp-internal/model-store": "^1.0.0",
|
||||
"@yaakapp-internal/proxy-lib": "^1.0.0",
|
||||
"@yaakapp-internal/theme": "^1.0.0",
|
||||
"@yaakapp-internal/ui": "^1.0.0",
|
||||
"classnames": "^2.5.1",
|
||||
"jotai": "^2.18.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"motion": "^14.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"vkbeautify": "^0.99.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
|
||||
@@ -93,8 +93,23 @@ pub enum Commands {
|
||||
/// Response commands
|
||||
Response(ResponseArgs),
|
||||
|
||||
/// Serve Yaak's MCP tools over stdio, for AI agents like Claude Code
|
||||
Mcp,
|
||||
/// Serve Yaak's MCP tools to AI agents like Claude Code, over stdio or with --http
|
||||
Mcp(McpArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct McpArgs {
|
||||
/// Serve over HTTP on 127.0.0.1 instead of stdio, for agents that cannot run this command
|
||||
#[arg(long)]
|
||||
pub http: bool,
|
||||
|
||||
/// Port to listen on with --http
|
||||
#[arg(long, default_value_t = yaak_mcp::http::DEFAULT_PORT, requires = "http")]
|
||||
pub port: u16,
|
||||
|
||||
/// Bearer token clients must send with --http; generated and printed when omitted
|
||||
#[arg(long, requires = "http", value_name = "TOKEN")]
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use crate::cli::McpArgs;
|
||||
use crate::context::{CliContext, CliExecutionContext};
|
||||
use crate::plugin_events::SendContexts;
|
||||
use rand::Rng;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||
@@ -13,7 +16,27 @@ use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::{PluginContext, ShowToastRequest};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
pub async fn run(ctx: &CliContext, environment: Option<&str>, cookie_jar_id: Option<&str>) -> i32 {
|
||||
enum Transport {
|
||||
Stdio,
|
||||
Http { port: u16, token: Option<String> },
|
||||
}
|
||||
|
||||
impl From<McpArgs> for Transport {
|
||||
fn from(args: McpArgs) -> Self {
|
||||
if args.http {
|
||||
Transport::Http { port: args.port, token: args.token }
|
||||
} else {
|
||||
Transport::Stdio
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
ctx: &CliContext,
|
||||
args: McpArgs,
|
||||
environment: Option<&str>,
|
||||
cookie_jar_id: Option<&str>,
|
||||
) -> i32 {
|
||||
let host = CliMcpHost {
|
||||
query_manager: ctx.query_manager().clone(),
|
||||
blob_manager: ctx.blob_manager().clone(),
|
||||
@@ -25,7 +48,11 @@ pub async fn run(ctx: &CliContext, environment: Option<&str>, cookie_jar_id: Opt
|
||||
environment_id: environment.map(str::to_string),
|
||||
cookie_jar_id: cookie_jar_id.map(str::to_string),
|
||||
};
|
||||
match yaak_mcp::serve_stdio(host).await {
|
||||
let result = match Transport::from(args) {
|
||||
Transport::Stdio => yaak_mcp::serve_stdio(host).await,
|
||||
Transport::Http { port, token } => serve_http(host, port, token).await,
|
||||
};
|
||||
match result {
|
||||
Ok(()) => 0,
|
||||
Err(error) => {
|
||||
eprintln!("Error: {error}");
|
||||
@@ -34,6 +61,24 @@ pub async fn run(ctx: &CliContext, environment: Option<&str>, cookie_jar_id: Opt
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_http(host: CliMcpHost, port: u16, token: Option<String>) -> Result<(), String> {
|
||||
let token = token.unwrap_or_else(generate_token);
|
||||
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
|
||||
let server = yaak_mcp::http::serve(Arc::new(host), addr, token.clone())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start MCP server on {addr}: {e}"))?;
|
||||
eprintln!("Yaak MCP server listening on http://{}{}", server.addr, yaak_mcp::http::PATH);
|
||||
eprintln!("Token: {token}");
|
||||
eprintln!("Clients must send it as \"Authorization: Bearer <token>\". Press Ctrl+C to stop.");
|
||||
tokio::signal::ctrl_c().await.map_err(|e| format!("Failed to wait for Ctrl+C: {e}"))?;
|
||||
server.stop().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_token() -> String {
|
||||
hex::encode(rand::thread_rng().r#gen::<[u8; 32]>())
|
||||
}
|
||||
|
||||
/// Every workspace in the database is "open", and the global `--environment`
|
||||
/// and `--cookie-jar` flags stand in for a window's selection.
|
||||
struct CliMcpHost {
|
||||
|
||||
@@ -184,7 +184,7 @@ async fn main() {
|
||||
context.shutdown().await;
|
||||
exit_code
|
||||
}
|
||||
Commands::Mcp => {
|
||||
Commands::Mcp(args) => {
|
||||
let mut context = CliContext::new(data_dir.clone(), app_id);
|
||||
context
|
||||
.init_plugins(CliExecutionContext {
|
||||
@@ -194,7 +194,8 @@ async fn main() {
|
||||
})
|
||||
.await;
|
||||
let exit_code =
|
||||
commands::mcp::run(&context, environment.as_deref(), cookie_jar.as_deref()).await;
|
||||
commands::mcp::run(&context, args, environment.as_deref(), cookie_jar.as_deref())
|
||||
.await;
|
||||
context.shutdown().await;
|
||||
exit_code
|
||||
}
|
||||
|
||||
@@ -222,3 +222,129 @@ async fn mcp_send_settings_reach_template_functions() {
|
||||
client.cancel().await.unwrap();
|
||||
assert!(child.wait().await.unwrap().success());
|
||||
}
|
||||
|
||||
/// Spawns `yaak mcp --http` with `args` and returns the child, the URL it
|
||||
/// printed, and the token it printed or was given.
|
||||
async fn spawn_http(data_dir: &std::path::Path, args: &[&str]) -> (tokio::process::Child, String, String) {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_yaak"))
|
||||
.arg("--data-dir")
|
||||
.arg(data_dir)
|
||||
.args(["mcp", "--http", "--port", "0"])
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut stderr = tokio::io::BufReader::new(child.stderr.take().unwrap()).lines();
|
||||
let mut url = None;
|
||||
let mut token = None;
|
||||
while url.is_none() || token.is_none() {
|
||||
let line = stderr.next_line().await.unwrap().expect("server exited before announcing");
|
||||
if let Some(rest) = line.strip_prefix("Yaak MCP server listening on ") {
|
||||
url = Some(rest.to_string());
|
||||
} else if let Some(rest) = line.strip_prefix("Token: ") {
|
||||
token = Some(rest.to_string());
|
||||
}
|
||||
}
|
||||
(child, url.unwrap(), token.unwrap())
|
||||
}
|
||||
|
||||
/// Posts one JSON-RPC message, with the session id when there is one, and
|
||||
/// returns the response and its body parsed out of either JSON or SSE.
|
||||
async fn post(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
auth: Option<&str>,
|
||||
session: Option<&str>,
|
||||
body: Value,
|
||||
) -> (reqwest::StatusCode, reqwest::header::HeaderMap, Option<Value>) {
|
||||
let mut request = client
|
||||
.post(url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json, text/event-stream")
|
||||
.body(body.to_string());
|
||||
if let Some(auth) = auth {
|
||||
request = request.header("Authorization", auth);
|
||||
}
|
||||
if let Some(session) = session {
|
||||
request = request.header("Mcp-Session-Id", session);
|
||||
}
|
||||
let response = request.send().await.unwrap();
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let text = response.text().await.unwrap();
|
||||
let message = text
|
||||
.lines()
|
||||
.filter_map(|l| l.strip_prefix("data:"))
|
||||
.last()
|
||||
.map(str::trim)
|
||||
.unwrap_or(text.trim());
|
||||
(status, headers, serde_json::from_str(message).ok())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_http_requires_the_token() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path();
|
||||
seed_workspace(data_dir, "wk_test");
|
||||
let (mut child, url, token) = spawn_http(data_dir, &["--token", "s3cret"]).await;
|
||||
assert_eq!(token, "s3cret");
|
||||
assert!(url.starts_with("http://127.0.0.1:"), "{url}");
|
||||
assert!(url.ends_with("/mcp"), "{url}");
|
||||
let client = reqwest::Client::new();
|
||||
let initialize = json!({
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "1.0" } }
|
||||
});
|
||||
|
||||
for auth in [None, Some("Bearer wrong")] {
|
||||
let (status, headers, _) = post(&client, &url, auth, None, initialize.clone()).await;
|
||||
assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED, "{auth:?}");
|
||||
assert_eq!(headers["www-authenticate"], "Bearer");
|
||||
}
|
||||
|
||||
let auth = Some("Bearer s3cret");
|
||||
let (status, headers, body) = post(&client, &url, auth, None, initialize).await;
|
||||
assert_eq!(status, reqwest::StatusCode::OK);
|
||||
let session = headers["mcp-session-id"].to_str().unwrap().to_string();
|
||||
assert_eq!(body.unwrap()["result"]["serverInfo"]["name"], "yaak-mcp-server");
|
||||
|
||||
let initialized = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
|
||||
let (status, _, _) = post(&client, &url, auth, Some(&session), initialized).await;
|
||||
assert_eq!(status, reqwest::StatusCode::ACCEPTED);
|
||||
|
||||
let list = json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} });
|
||||
let (status, _, body) = post(&client, &url, auth, Some(&session), list).await;
|
||||
assert_eq!(status, reqwest::StatusCode::OK);
|
||||
let tools = body.unwrap()["result"]["tools"].as_array().unwrap().len();
|
||||
assert_eq!(tools, 15);
|
||||
|
||||
child.kill().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_http_generates_a_token() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path();
|
||||
seed_workspace(data_dir, "wk_test");
|
||||
let (mut child, url, token) = spawn_http(data_dir, &[]).await;
|
||||
assert_eq!(token.len(), 64, "{token}");
|
||||
assert!(token.chars().all(|c| c.is_ascii_hexdigit()), "{token}");
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let initialize = json!({
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "1.0" } }
|
||||
});
|
||||
let (status, _, _) = post(&client, &url, None, None, initialize.clone()).await;
|
||||
assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED);
|
||||
let (status, _, _) =
|
||||
post(&client, &url, Some(&format!("Bearer {token}")), None, initialize).await;
|
||||
assert_eq!(status, reqwest::StatusCode::OK);
|
||||
|
||||
child.kill().await.unwrap();
|
||||
}
|
||||
@@ -6,24 +6,68 @@ authors = ["Gregory Schier"]
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.23.1"
|
||||
bytes = "1"
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
log = { workspace = true }
|
||||
http-body-util = "0.1"
|
||||
hyper = { version = "1", features = ["http1", "server"] }
|
||||
hyper-util = { version = "0.1", features = ["server-graceful", "tokio"] }
|
||||
include_dir = "0.7"
|
||||
r2d2 = "0.8.10"
|
||||
r2d2_sqlite = "0.32"
|
||||
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
rusqlite = { version = "0.38", features = ["bundled", "chrono", "functions"] }
|
||||
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["io-util"] }
|
||||
tokio = { workspace = true, features = ["io-util", "macros", "net", "process", "rt-multi-thread"] }
|
||||
ts-rs = { workspace = true, features = ["chrono-impl"] }
|
||||
url = "2"
|
||||
yaak-common = { workspace = true }
|
||||
yaak-database = { workspace = true }
|
||||
yaak-filter = { workspace = true }
|
||||
yaak-http = { workspace = true }
|
||||
yaak-lifecycle = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
yaak-proxy = { workspace = true }
|
||||
yaak-proxy-helper = { workspace = true, features = ["ts"] }
|
||||
yaak-rpc = { workspace = true }
|
||||
yaak-templates = { workspace = true }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"objc2-core-foundation",
|
||||
"NSBitmapImageRep",
|
||||
"NSGraphics",
|
||||
"NSGraphicsContext",
|
||||
"NSImage",
|
||||
"NSImageRep",
|
||||
"NSWorkspace",
|
||||
] }
|
||||
objc2-foundation = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"NSData",
|
||||
"NSDictionary",
|
||||
"NSError",
|
||||
"NSGeometry",
|
||||
"NSString",
|
||||
] }
|
||||
objc2-service-management = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"SMAppService",
|
||||
] }
|
||||
plist = "1.7"
|
||||
|
||||
[dev-dependencies]
|
||||
base64 = "0.23.1"
|
||||
bytes = "1"
|
||||
flate2 = "1"
|
||||
http-body-util = "0.1"
|
||||
@@ -32,3 +76,4 @@ hyper-util = { version = "0.1", features = ["tokio"] }
|
||||
reqwest = { workspace = true }
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "time"] }
|
||||
yaak-proxy-helper = { workspace = true, features = ["test-support"] }
|
||||
+28
-1
@@ -40,7 +40,34 @@ elapsedHeadersMs: number | null,
|
||||
/**
|
||||
* Milliseconds from `started_at` to the end of the response.
|
||||
*/
|
||||
elapsedMs: number | null, error: string | null, };
|
||||
elapsedMs: number | null, error: string | null,
|
||||
/**
|
||||
* The client gave up before the response ended, by resetting its HTTP/2 stream or closing
|
||||
* the connection. Not an error.
|
||||
*/
|
||||
cancelled: boolean,
|
||||
/**
|
||||
* The id of the Yaak request this flow was last saved as.
|
||||
*/
|
||||
savedRequestId: string | null,
|
||||
/**
|
||||
* The process that opened the client connection, where the platform can tell (macOS).
|
||||
*/
|
||||
clientPid: number | null,
|
||||
/**
|
||||
* Its app's name, or its executable's outside an app: "Google Chrome", "curl".
|
||||
*/
|
||||
clientName: string | null,
|
||||
/**
|
||||
* Its app's bundle id.
|
||||
*/
|
||||
clientBundleId: string | null,
|
||||
/**
|
||||
* Its app bundle's path, or its executable's outside an app.
|
||||
*/
|
||||
clientPath: string | null, };
|
||||
|
||||
export type FlowBody = "request" | "response";
|
||||
|
||||
export type ModelPayload = { model: Flow, change: ModelChangeEvent, };
|
||||
|
||||
|
||||
+310
-7
@@ -1,28 +1,331 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Flow, ModelPayload } from "./gen_models";
|
||||
import type { Flow, FlowBody, ModelPayload } from "./gen_models";
|
||||
|
||||
export type ActionInvocation = { "scope": "global", action: GlobalAction, };
|
||||
|
||||
export type ActionMetadata = { label: string, defaultHotkey: string | null, };
|
||||
|
||||
export type AppIconRequest = {
|
||||
/**
|
||||
* A flow's `clientPath`.
|
||||
*/
|
||||
path: string, };
|
||||
|
||||
export type CaStatus = {
|
||||
/**
|
||||
* macOS trusts this app's CA, so HTTPS flows can be decrypted.
|
||||
*/
|
||||
trusted: boolean,
|
||||
/**
|
||||
* A certificate named "Yaak Proxy CA" is in a keychain: this one, or an older one.
|
||||
*/
|
||||
installed: boolean, certPath: string, };
|
||||
|
||||
export type CaStatusRequest = Record<string, never>;
|
||||
|
||||
export type CaTrustRequest = Record<string, never>;
|
||||
|
||||
export type ClearFlowsRequest = Record<string, never>;
|
||||
|
||||
/**
|
||||
* The app behind a client connection.
|
||||
*/
|
||||
export type ClientApp = { pid: number,
|
||||
/**
|
||||
* The app's name ("Google Chrome"), or the executable's for a process outside an app ("curl").
|
||||
*/
|
||||
name: string,
|
||||
/**
|
||||
* The outermost `.app` bundle's CFBundleIdentifier, if any.
|
||||
*/
|
||||
bundleId: string | null,
|
||||
/**
|
||||
* The outermost `.app` bundle's path, or the executable's path when there is no bundle.
|
||||
*/
|
||||
path: string, };
|
||||
|
||||
export type DropReason = "hop_by_hop" | "set_by_sending" | "body_decoded";
|
||||
|
||||
export type DroppedHeader = { name: string, value: string, reason: DropReason, };
|
||||
|
||||
export type ExportHarRequest = {
|
||||
/**
|
||||
* The file to write.
|
||||
*/
|
||||
path: string,
|
||||
/**
|
||||
* These flows, or else every flow the filter covers.
|
||||
*/
|
||||
ids?: Array<string>, filter?: FlowFilter, };
|
||||
|
||||
export type FilterBodyRequest = { flowId: string, which: FlowBody,
|
||||
/**
|
||||
* JSONPath, or XPath for an XML or HTML body.
|
||||
*/
|
||||
expression: string,
|
||||
/**
|
||||
* The language the viewer detected from the body. Without it, the Content-Type decides.
|
||||
*/
|
||||
language?: FilterLanguage, };
|
||||
|
||||
export type FilterBodyResponse = { content: string, error: string | null, };
|
||||
|
||||
export type FilterLanguage = "jsonpath" | "xpath";
|
||||
|
||||
/**
|
||||
* Which flows a listing or a HAR export covers. Every field narrows it; none is all of them.
|
||||
*/
|
||||
export type FlowFilter = {
|
||||
/**
|
||||
* Search terms, any case, each of which must match the URL (host, path or query), the
|
||||
* method, the status (`404`, `40`, `4xx`) or the content type: `stripe post 4xx`.
|
||||
*/
|
||||
q?: string,
|
||||
/**
|
||||
* Part of the host, any case: `stripe` matches `api.stripe.com`.
|
||||
*/
|
||||
host?: string,
|
||||
/**
|
||||
* Only flows under one of these hosts or paths, as the app's sidebar names them:
|
||||
* `api.stripe.com` or `api.stripe.com/v1/charges`, with any port after the host.
|
||||
*/
|
||||
under?: Array<string>,
|
||||
/**
|
||||
* Only flows from one of these apps, by bundle id, or by path for a process outside an
|
||||
* app bundle.
|
||||
*/
|
||||
app?: Array<string>,
|
||||
/**
|
||||
* The method, any case.
|
||||
*/
|
||||
method?: string,
|
||||
/**
|
||||
* A status (`404`), a class of them (`4xx`), or `error` for flows that failed.
|
||||
*/
|
||||
status?: string,
|
||||
/**
|
||||
* At most this many flows, the newest.
|
||||
*/
|
||||
limit?: number,
|
||||
/**
|
||||
* Only flows recorded after the flow with this id, for following new traffic. Flows are
|
||||
* recorded when they end, so a slow one that started earlier still comes through.
|
||||
*/
|
||||
after?: string,
|
||||
/**
|
||||
* Only flows older than the flow with this id, by start and then id, for paging back
|
||||
* through older traffic.
|
||||
*/
|
||||
before?: string, };
|
||||
|
||||
export type FlowsClearedPayload = Record<string, never>;
|
||||
|
||||
export type FolderSummary = { id: string, name: string,
|
||||
/**
|
||||
* The folder this one is in, if any.
|
||||
*/
|
||||
folderId: string | null, };
|
||||
|
||||
export type FormatBodyRequest = { text: string, };
|
||||
|
||||
export type GetFlowBodyRequest = { flowId: string, which: FlowBody, };
|
||||
|
||||
export type GetFlowBodyResponse = {
|
||||
/**
|
||||
* The body decoded with its Content-Encoding, at most the capture limit of it, in base64.
|
||||
*/
|
||||
data: string,
|
||||
/**
|
||||
* The body is incomplete: cut on the wire, or decoded past the capture limit.
|
||||
*/
|
||||
truncated: boolean,
|
||||
/**
|
||||
* Bytes stored for the body, as they were on the wire.
|
||||
*/
|
||||
storedSize: number, };
|
||||
|
||||
export type GetProxyStateRequest = Record<string, never>;
|
||||
|
||||
export type GetProxyStateResponse = { state: ProxyState, };
|
||||
export type GetProxyStateResponse = { state: ProxyState,
|
||||
/**
|
||||
* The port the engine is bound to, while it runs.
|
||||
*/
|
||||
port: number | null,
|
||||
/**
|
||||
* The port the engine starts on.
|
||||
*/
|
||||
configuredPort: number,
|
||||
/**
|
||||
* Flows are stored. Paused, traffic still passes through, unrecorded.
|
||||
*/
|
||||
recording: boolean,
|
||||
/**
|
||||
* The system proxy turns on as the engine starts.
|
||||
*/
|
||||
systemProxyAtLaunch: boolean,
|
||||
/**
|
||||
* The HTTP versions offered to clients.
|
||||
*/
|
||||
httpVersion: HttpVersion,
|
||||
/**
|
||||
* Why the engine last failed to start, until it starts.
|
||||
*/
|
||||
startError: string | null,
|
||||
/**
|
||||
* Another process that owns the engine, such as the CLI daemon. While one does, this app
|
||||
* cannot start its own, and new flows reach it only through the database.
|
||||
*/
|
||||
otherOwner: OtherOwner | null, };
|
||||
|
||||
export type GlobalAction = "proxy_start" | "proxy_stop";
|
||||
export type GlobalAction = "recording_toggle" | "flows_clear";
|
||||
|
||||
export type HelperRegisterRequest = Record<string, never>;
|
||||
|
||||
export type HelperState = "unavailable" | "not_registered" | "requires_approval" | "enabled" | "not_found";
|
||||
|
||||
export type HelperUnregisterRequest = Record<string, never>;
|
||||
|
||||
/**
|
||||
* The HTTP versions offered to clients in intercepted HTTPS. Upstream, the engine speaks
|
||||
* whichever the server picks either way.
|
||||
*/
|
||||
export type HttpVersion = "auto" | "http1Only";
|
||||
|
||||
export type ListActionsRequest = Record<string, never>;
|
||||
|
||||
export type ListActionsResponse = { actions: Array<[ActionInvocation, ActionMetadata]>, };
|
||||
|
||||
export type ListModelsRequest = Record<string, never>;
|
||||
export type ListFlowsResponse = {
|
||||
/**
|
||||
* Newest first.
|
||||
*/
|
||||
flows: Array<Flow>,
|
||||
/**
|
||||
* Flows in the database, whatever the filter.
|
||||
*/
|
||||
total: number,
|
||||
/**
|
||||
* Flows the filter covers, whatever the limit, when it narrows to fewer than all of them.
|
||||
*/
|
||||
matching: number | null, };
|
||||
|
||||
export type ListModelsResponse = { flows: Array<Flow>, };
|
||||
export type ListWorkspacesRequest = Record<string, never>;
|
||||
|
||||
export type ListWorkspacesResponse = {
|
||||
/**
|
||||
* Yaak has run on this machine. Without it there is nothing to save into.
|
||||
*/
|
||||
yaakFound: boolean, workspaces: Array<WorkspaceSummary>, };
|
||||
|
||||
export type OtherOwner = { owner: Owner, pid: number, startedAt: string,
|
||||
/**
|
||||
* The engine's port while it runs.
|
||||
*/
|
||||
proxyPort: number | null, };
|
||||
|
||||
/**
|
||||
* The process that runs the engine.
|
||||
*/
|
||||
export type Owner = "app" | "cli";
|
||||
|
||||
export type PreviewSaveRequest = { flowId: string, };
|
||||
|
||||
export type ProxySetting = { enabled: boolean, server: string, port: number, };
|
||||
|
||||
export type ProxyState = "running" | "stopped";
|
||||
|
||||
export type ProxyStatePayload = { state: ProxyState, };
|
||||
|
||||
export type RpcEventSchema = { model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, };
|
||||
export type RpcEventSchema = { flows_cleared: FlowsClearedPayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, };
|
||||
|
||||
export type RpcSchema = { execute_action: [ActionInvocation, boolean], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_models: [ListModelsRequest, ListModelsResponse], };
|
||||
export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], helper_register: [HelperRegisterRequest, SystemProxyStatus], helper_unregister: [HelperUnregisterRequest, SystemProxyStatus], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], save_flow: [SaveFlowToYaakRequest, SavedFlow], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], };
|
||||
|
||||
export type SaveFlowToYaakRequest = { flowId: string, workspaceId: string, folderId?: string,
|
||||
/**
|
||||
* None or blank leaves the request unnamed, and Yaak shows its URL.
|
||||
*/
|
||||
name?: string, };
|
||||
|
||||
/**
|
||||
* What saving a flow as a Yaak request writes, for the save dialog.
|
||||
*/
|
||||
export type SavePreview = { method: string, url: string,
|
||||
/**
|
||||
* The URL as saved, its query split out into parameters, which Yaak shows for an unnamed
|
||||
* request.
|
||||
*/
|
||||
requestUrl: string,
|
||||
/**
|
||||
* The saved body type, which Yaak shows for an unnamed GraphQL request with no URL.
|
||||
*/
|
||||
bodyType: string | null,
|
||||
/**
|
||||
* Headers Yaak sets itself or that only meant something to the proxy.
|
||||
*/
|
||||
droppedHeaders: Array<DroppedHeader>,
|
||||
/**
|
||||
* Headers saved as captured, Authorization among them.
|
||||
*/
|
||||
keptHeaders: number,
|
||||
/**
|
||||
* The body as it is saved: "JSON", "form URL-encoded with 7 fields". None without a body.
|
||||
*/
|
||||
body: string | null,
|
||||
/**
|
||||
* What does not carry over exactly, one sentence each.
|
||||
*/
|
||||
warnings: Array<string>,
|
||||
/**
|
||||
* An Authorization header is saved in plain text.
|
||||
*/
|
||||
authorization: boolean, };
|
||||
|
||||
export type SavedFlow = { requestId: string, name: string, };
|
||||
|
||||
export type ServiceProxy = {
|
||||
/**
|
||||
* The network service, as System Settings names it: "Wi-Fi", "USB 10/100/1000 LAN".
|
||||
*/
|
||||
name: string,
|
||||
/**
|
||||
* What stays the same when the service is renamed: its device ("en0"), or its hardware
|
||||
* port when it has none. Absent when `networksetup` lists neither.
|
||||
*/
|
||||
id?: string, http: ProxySetting, https: ProxySetting, };
|
||||
|
||||
export type SetHttpVersionRequest = { httpVersion: HttpVersion, };
|
||||
|
||||
export type SetRecordingRequest = { enabled: boolean, };
|
||||
|
||||
export type SetSystemProxyAtLaunchRequest = { enabled: boolean, };
|
||||
|
||||
/**
|
||||
* How a change gets administrator rights.
|
||||
*/
|
||||
export type SystemProxyBackend = "helper" | "admin_prompt" | "sudo";
|
||||
|
||||
export type SystemProxySetRequest = { enabled: boolean, };
|
||||
|
||||
export type SystemProxyStatus = {
|
||||
/**
|
||||
* Every enabled network service sends HTTP and HTTPS to `127.0.0.1:<port>`.
|
||||
*/
|
||||
enabled: boolean,
|
||||
/**
|
||||
* The port checked against.
|
||||
*/
|
||||
port: number, services: Array<ServiceProxy>, backend: SystemProxyBackend,
|
||||
/**
|
||||
* The privileged helper's registration, which decides the backend.
|
||||
*/
|
||||
helper: HelperState,
|
||||
/**
|
||||
* This platform has a system proxy the app can set.
|
||||
*/
|
||||
supported: boolean, };
|
||||
|
||||
export type SystemProxyStatusRequest = Record<string, never>;
|
||||
|
||||
export type TakeOverRequest = Record<string, never>;
|
||||
|
||||
export type WorkspaceSummary = { id: string, name: string, folders: Array<FolderSummary>, };
|
||||
@@ -0,0 +1,2 @@
|
||||
-- The Yaak request a flow was last saved as.
|
||||
ALTER TABLE flows ADD COLUMN saved_request_id TEXT;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- The app that opened the flow's client connection, where the platform can tell.
|
||||
ALTER TABLE flows ADD COLUMN client_pid INTEGER;
|
||||
ALTER TABLE flows ADD COLUMN client_name TEXT;
|
||||
ALTER TABLE flows ADD COLUMN client_bundle_id TEXT;
|
||||
ALTER TABLE flows ADD COLUMN client_path TEXT;
|
||||
|
||||
-- Flows list and page newest first, by start and then id.
|
||||
DROP INDEX idx_flows_started_at;
|
||||
CREATE INDEX idx_flows_started_at_id ON flows (started_at, id);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- The client gave up on the flow, by resetting its HTTP/2 stream or closing the connection.
|
||||
ALTER TABLE flows ADD COLUMN cancelled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -5,8 +5,9 @@ use ts_rs::TS;
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub enum GlobalAction {
|
||||
ProxyStart,
|
||||
ProxyStop,
|
||||
/// Pauses or resumes storing flows.
|
||||
RecordingToggle,
|
||||
FlowsClear,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
@@ -32,17 +33,18 @@ fn default_hotkey(mac: &str, other: &str) -> Option<String> {
|
||||
pub fn all_global_actions() -> Vec<(ActionInvocation, ActionMetadata)> {
|
||||
vec![
|
||||
(
|
||||
ActionInvocation::Global { action: GlobalAction::ProxyStart },
|
||||
ActionInvocation::Global { action: GlobalAction::RecordingToggle },
|
||||
ActionMetadata {
|
||||
label: "Start Proxy".into(),
|
||||
default_hotkey: default_hotkey("Meta+Shift+P", "Ctrl+Shift+P"),
|
||||
label: "Pause or Resume Recording".into(),
|
||||
// Not Cmd+Shift+R, which reloads the window in development builds.
|
||||
default_hotkey: default_hotkey("Meta+R", "Ctrl+Shift+R"),
|
||||
},
|
||||
),
|
||||
(
|
||||
ActionInvocation::Global { action: GlobalAction::ProxyStop },
|
||||
ActionInvocation::Global { action: GlobalAction::FlowsClear },
|
||||
ActionMetadata {
|
||||
label: "Stop Proxy".into(),
|
||||
default_hotkey: default_hotkey("Meta+Shift+S", "Ctrl+Shift+S"),
|
||||
label: "Clear Flows".into(),
|
||||
default_hotkey: default_hotkey("Meta+K", "Ctrl+K"),
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -15,23 +15,116 @@ pub fn read_raw_body(
|
||||
Ok(chunks.into_iter().flat_map(|c| c.data).collect())
|
||||
}
|
||||
|
||||
/// A flow's body decoded with its Content-Encoding. A truncated body decodes as far as its
|
||||
/// stored bytes go.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DecodedBody {
|
||||
pub data: Vec<u8>,
|
||||
/// Bytes stored for the body, as they were on the wire.
|
||||
pub stored_size: usize,
|
||||
/// The body is incomplete: cut on the wire at the capture limit, decoded past `limit`, or
|
||||
/// decoded only as far as `decode_error`.
|
||||
pub truncated: bool,
|
||||
/// Why decoding stopped before the end of the stored bytes.
|
||||
pub decode_error: Option<String>,
|
||||
}
|
||||
|
||||
/// A flow's body decoded with its Content-Encoding, at most `limit` bytes of it. A body that
|
||||
/// fails to decode, as one cut short does, keeps what decoded before the failure.
|
||||
pub async fn read_body(
|
||||
blobs: &BlobManager,
|
||||
flow: &Flow,
|
||||
body: FlowBody,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
limit: usize,
|
||||
) -> Result<DecodedBody, String> {
|
||||
let raw = read_raw_body(blobs, &flow.id, body)?;
|
||||
let encoding = ContentEncoding::from_header(find_header(flow.headers(body), "content-encoding"));
|
||||
if encoding == ContentEncoding::Identity {
|
||||
return Ok(raw);
|
||||
let stored_size = raw.len();
|
||||
// A response that failed or was cancelled after its headers ended short on the wire.
|
||||
let cut = body == FlowBody::Response
|
||||
&& flow.status.is_some()
|
||||
&& (flow.error.is_some() || flow.cancelled);
|
||||
let wire_truncated = flow.truncated(body) || cut;
|
||||
let encoding =
|
||||
ContentEncoding::from_header(find_header(flow.headers(body), "content-encoding"));
|
||||
// A HEAD or 304 response names its encoding but has no body to decode.
|
||||
if raw.is_empty() || encoding == ContentEncoding::Identity {
|
||||
let truncated = wire_truncated || raw.len() > limit;
|
||||
let mut data = raw;
|
||||
data.truncate(limit);
|
||||
return Ok(DecodedBody { data, stored_size, truncated, decode_error: None });
|
||||
}
|
||||
|
||||
let mut decoded = Vec::new();
|
||||
match streaming_decoder(Cursor::new(raw), encoding).read_to_end(&mut decoded).await {
|
||||
Ok(_) => Ok(decoded),
|
||||
Err(_) if flow.truncated(body) => Ok(decoded),
|
||||
Err(e) => Err(format!("Failed to decode {encoding:?} body: {e}")),
|
||||
// One byte past the limit tells a body that fills it from one that overflows it.
|
||||
let take = u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let mut data = Vec::new();
|
||||
let mut decoder = streaming_decoder(Cursor::new(raw), encoding).take(take);
|
||||
let decode_error = match decoder.read_to_end(&mut data).await {
|
||||
Ok(_) => None,
|
||||
// Expected of a body cut on the wire; anything else is worth saying.
|
||||
Err(_) if wire_truncated => None,
|
||||
Err(e) => Some(format!("Failed to decode {encoding:?} body: {e}")),
|
||||
};
|
||||
let truncated = wire_truncated || decode_error.is_some() || data.len() > limit;
|
||||
data.truncate(limit);
|
||||
Ok(DecodedBody { data, stored_size, truncated, decode_error })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use yaak_models::blob_manager::BodyChunkWriter;
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_failed_after_its_headers_is_truncated() {
|
||||
let (_db, blobs, _rx) = yaak_models::init_in_memory().unwrap();
|
||||
let mut writer =
|
||||
BodyChunkWriter::new(blobs.clone(), Flow::body_id("fl_cut", FlowBody::Response));
|
||||
writer.write(b"{\"rows\": [").unwrap();
|
||||
writer.finish().unwrap();
|
||||
let cut = Flow {
|
||||
id: "fl_cut".into(),
|
||||
status: Some(200),
|
||||
error: Some("error reading a body from connection".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let body = read_body(&blobs, &cut, FlowBody::Response, 1024).await.unwrap();
|
||||
assert_eq!((body.data.as_slice(), body.truncated), (&b"{\"rows\": ["[..], true));
|
||||
// The request went out whole; the error is the response's.
|
||||
let request = read_body(&blobs, &cut, FlowBody::Request, 1024).await.unwrap();
|
||||
assert!(!request.truncated);
|
||||
// With no headers there was never a response body to cut.
|
||||
let refused = Flow { status: None, ..cut.clone() };
|
||||
assert!(!read_body(&blobs, &refused, FlowBody::Response, 1024).await.unwrap().truncated);
|
||||
|
||||
// The client cancelling partway through cuts it short the same way.
|
||||
let cancelled = Flow { error: None, cancelled: true, ..cut };
|
||||
let body = read_body(&blobs, &cancelled, FlowBody::Response, 1024).await.unwrap();
|
||||
assert_eq!((body.data.as_slice(), body.truncated), (&b"{\"rows\": ["[..], true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_gzip_response_keeps_what_decoded() {
|
||||
use std::io::Write;
|
||||
let (_db, blobs, _rx) = yaak_models::init_in_memory().unwrap();
|
||||
let mut gzip = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
gzip.write_all(&[b'z'; 4096]).unwrap();
|
||||
let gzip = gzip.finish().unwrap();
|
||||
let mut writer =
|
||||
BodyChunkWriter::new(blobs.clone(), Flow::body_id("fl_gz", FlowBody::Response));
|
||||
writer.write(&gzip[..gzip.len() / 2]).unwrap();
|
||||
writer.finish().unwrap();
|
||||
let flow = Flow {
|
||||
id: "fl_gz".into(),
|
||||
status: Some(200),
|
||||
cancelled: true,
|
||||
response_headers: vec![crate::models::ProxyHeader {
|
||||
name: "content-encoding".into(),
|
||||
value: "gzip".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let body = read_body(&blobs, &flow, FlowBody::Response, 8192).await.unwrap();
|
||||
assert!(body.truncated);
|
||||
assert_eq!(body.decode_error, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CaStatus {
|
||||
/// macOS trusts this app's CA, so HTTPS flows can be decrypted.
|
||||
pub trusted: bool,
|
||||
/// A certificate named "Yaak Proxy CA" is in a keychain: this one, or an older one.
|
||||
pub installed: bool,
|
||||
pub cert_path: String,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const CA_NAME: &str = "Yaak Proxy CA";
|
||||
#[cfg(target_os = "macos")]
|
||||
const SECURITY: &str = "/usr/bin/security";
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub async fn status(cert_path: &Path) -> Result<CaStatus, String> {
|
||||
let installed = security(&["find-certificate", "-c", CA_NAME]).await?.status.success();
|
||||
let path = cert_path.to_string_lossy();
|
||||
// verify-cert fails both for a missing file and for an older CA trusted under the same name.
|
||||
let trusted = installed
|
||||
&& cert_path.exists()
|
||||
&& security(&["verify-cert", "-c", &path, "-L", "-q"]).await?.status.success();
|
||||
Ok(CaStatus { trusted, installed, cert_path: path.into_owned() })
|
||||
}
|
||||
|
||||
/// Adds the CA to the login keychain as a trusted root. macOS asks for the user's password;
|
||||
/// the app never elevates.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub async fn trust(cert_path: &Path) -> Result<(), String> {
|
||||
let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?;
|
||||
let keychain = Path::new(&home).join("Library/Keychains/login.keychain-db");
|
||||
let output = security(&[
|
||||
"add-trusted-cert",
|
||||
"-r",
|
||||
"trustRoot",
|
||||
"-k",
|
||||
&keychain.to_string_lossy(),
|
||||
&cert_path.to_string_lossy(),
|
||||
])
|
||||
.await?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if stderr.is_empty() { format!("security exited with {}", output.status) } else { stderr })
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn security(args: &[&str]) -> Result<std::process::Output, String> {
|
||||
tokio::process::Command::new(SECURITY)
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to run {SECURITY}: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub async fn status(cert_path: &Path) -> Result<CaStatus, String> {
|
||||
Ok(CaStatus {
|
||||
trusted: false,
|
||||
installed: false,
|
||||
cert_path: cert_path.to_string_lossy().into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub async fn trust(_cert_path: &Path) -> Result<(), String> {
|
||||
Err("Trusting the certificate from the app is only supported on macOS".to_string())
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! The other side of the control API, for the CLI and the MCP tools: find the owner through
|
||||
//! `control.json` and call it with its token, or, when nothing owns the engine, read `proxy.db`
|
||||
//! and `blobs.sqlite` read-only so captured traffic stays reachable.
|
||||
|
||||
use crate::bodies::{self, DecodedBody};
|
||||
use crate::ca::CaStatus;
|
||||
use crate::control::{
|
||||
self, ControlInfo, ControlStatus, ErrorBody, RecordingRequest, StartRequest,
|
||||
SystemProxyRequest, TRUNCATED_HEADER,
|
||||
};
|
||||
use crate::db::ProxyQueryManager;
|
||||
use crate::flows::{self, FlowDetail, FlowFilter};
|
||||
use crate::har::{self, Har};
|
||||
use crate::macos::SystemProxyStatus;
|
||||
use crate::models::{Flow, FlowBody};
|
||||
use crate::save::{SaveFlowRequest, SaveFlowResponse, WorkspaceSummary};
|
||||
use crate::settings::ProxySettings;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::path::Path;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
|
||||
/// Where captured traffic is read from.
|
||||
pub enum ProxyConnection {
|
||||
/// A process owns the engine; everything goes through its control API.
|
||||
Live(ControlClient),
|
||||
/// Nothing owns the engine; flows are read from disk.
|
||||
Offline(OfflineStore),
|
||||
}
|
||||
|
||||
impl ProxyConnection {
|
||||
/// The live owner in `data_dir`, or its databases when there is none.
|
||||
pub fn connect(data_dir: &Path) -> Result<Self, String> {
|
||||
match control::live_owner(data_dir) {
|
||||
Some(info) => Ok(Self::Live(ControlClient::new(info))),
|
||||
None => Ok(Self::Offline(OfflineStore::open(data_dir)?)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn flows(&self, filter: &FlowFilter) -> Result<Vec<Flow>, String> {
|
||||
match self {
|
||||
Self::Live(c) => c.flows(filter).await,
|
||||
Self::Offline(s) => s.flows(filter),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn flow(&self, id: &str, body_limit: usize) -> Result<FlowDetail, String> {
|
||||
match self {
|
||||
Self::Live(c) => c.flow(id, body_limit).await,
|
||||
Self::Offline(s) => s.flow(id, body_limit).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn body(&self, id: &str, which: FlowBody) -> Result<DecodedBody, String> {
|
||||
match self {
|
||||
Self::Live(c) => c.body(id, which).await,
|
||||
Self::Offline(s) => s.body(id, which).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn har(&self, filter: &FlowFilter) -> Result<Har, String> {
|
||||
match self {
|
||||
Self::Live(c) => c.har(filter).await,
|
||||
Self::Offline(s) => s.har(filter).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls the control API of the owner `control.json` names.
|
||||
pub struct ControlClient {
|
||||
info: ControlInfo,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl ControlClient {
|
||||
pub fn new(info: ControlInfo) -> Self {
|
||||
// Never through the system proxy, which may be the engine itself.
|
||||
let http = reqwest::Client::builder().no_proxy().build().unwrap_or_default();
|
||||
Self { info, http }
|
||||
}
|
||||
|
||||
pub fn info(&self) -> &ControlInfo {
|
||||
&self.info
|
||||
}
|
||||
|
||||
pub async fn status(&self) -> Result<ControlStatus, String> {
|
||||
self.get("/status").await
|
||||
}
|
||||
|
||||
pub async fn start(&self, port: Option<u16>) -> Result<ControlStatus, String> {
|
||||
self.post("/start", &StartRequest { port }).await
|
||||
}
|
||||
|
||||
pub async fn stop(&self) -> Result<ControlStatus, String> {
|
||||
self.post("/stop", &serde_json::json!({})).await
|
||||
}
|
||||
|
||||
pub async fn flows(&self, filter: &FlowFilter) -> Result<Vec<Flow>, String> {
|
||||
self.get(&format!("/flows?{}", filter.to_query())).await
|
||||
}
|
||||
|
||||
pub async fn flow(&self, id: &str, body_limit: usize) -> Result<FlowDetail, String> {
|
||||
self.get(&format!("/flows/{}?bodyLimit={body_limit}", encode(id))).await
|
||||
}
|
||||
|
||||
pub async fn body(&self, id: &str, which: FlowBody) -> Result<DecodedBody, String> {
|
||||
let side = match which {
|
||||
FlowBody::Request => "request-body",
|
||||
FlowBody::Response => "response-body",
|
||||
};
|
||||
let resp =
|
||||
self.send(self.http.get(self.url(&format!("/flows/{}/{side}", encode(id))))).await?;
|
||||
let truncated = resp.headers().get(TRUNCATED_HEADER).is_some_and(|v| v == "true");
|
||||
let data = resp.bytes().await.map_err(|e| e.to_string())?.to_vec();
|
||||
Ok(DecodedBody { stored_size: data.len(), data, truncated, decode_error: None })
|
||||
}
|
||||
|
||||
pub async fn har(&self, filter: &FlowFilter) -> Result<Har, String> {
|
||||
self.get(&format!("/har?{}", filter.to_query())).await
|
||||
}
|
||||
|
||||
pub async fn ca_pem(&self) -> Result<String, String> {
|
||||
let resp = self.send(self.http.get(self.url("/ca"))).await?;
|
||||
resp.text().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn trust_ca(&self) -> Result<CaStatus, String> {
|
||||
self.post("/ca/trust", &serde_json::json!({})).await
|
||||
}
|
||||
|
||||
/// Resumes or pauses storing flows in the owner's engine.
|
||||
pub async fn set_recording(&self, enabled: bool) -> Result<ControlStatus, String> {
|
||||
self.post("/recording", &RecordingRequest { enabled }).await
|
||||
}
|
||||
|
||||
pub async fn system_proxy(&self) -> Result<SystemProxyStatus, String> {
|
||||
self.get("/system-proxy").await
|
||||
}
|
||||
|
||||
pub async fn set_system_proxy(&self, enabled: bool) -> Result<SystemProxyStatus, String> {
|
||||
self.post("/system-proxy", &SystemProxyRequest { enabled }).await
|
||||
}
|
||||
|
||||
pub async fn workspaces(&self) -> Result<Vec<WorkspaceSummary>, String> {
|
||||
self.get("/workspaces").await
|
||||
}
|
||||
|
||||
pub async fn save(&self, id: &str, req: &SaveFlowRequest) -> Result<SaveFlowResponse, String> {
|
||||
self.post(&format!("/flows/{}/save", encode(id)), req).await
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}{path}", self.info.base_url())
|
||||
}
|
||||
|
||||
async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, String> {
|
||||
let resp = self.send(self.http.get(self.url(path))).await?;
|
||||
resp.json().await.map_err(|e| format!("Invalid response from the proxy: {e}"))
|
||||
}
|
||||
|
||||
async fn post<B: Serialize, T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> Result<T, String> {
|
||||
let body = serde_json::to_vec(body).map_err(|e| e.to_string())?;
|
||||
let req =
|
||||
self.http.post(self.url(path)).header("content-type", "application/json").body(body);
|
||||
let resp = self.send(req).await?;
|
||||
resp.json().await.map_err(|e| format!("Invalid response from the proxy: {e}"))
|
||||
}
|
||||
|
||||
async fn send(&self, req: reqwest::RequestBuilder) -> Result<reqwest::Response, String> {
|
||||
let resp = req
|
||||
.bearer_auth(&self.info.token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to reach the proxy's control API: {e}"))?;
|
||||
if resp.status().is_success() {
|
||||
return Ok(resp);
|
||||
}
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
Err(serde_json::from_str::<ErrorBody>(&text)
|
||||
.map(|e| e.error)
|
||||
.unwrap_or_else(|_| format!("The proxy answered {status}: {text}")))
|
||||
}
|
||||
}
|
||||
|
||||
fn encode(segment: &str) -> String {
|
||||
url::form_urlencoded::byte_serialize(segment.as_bytes()).collect()
|
||||
}
|
||||
|
||||
/// `proxy.db` and `blobs.sqlite`, opened read-only while no process owns the engine.
|
||||
pub struct OfflineStore {
|
||||
db: ProxyQueryManager,
|
||||
blobs: BlobManager,
|
||||
capture_body_limit: usize,
|
||||
}
|
||||
|
||||
impl OfflineStore {
|
||||
pub fn open(data_dir: &Path) -> Result<Self, String> {
|
||||
let db_path = data_dir.join("proxy.db");
|
||||
if !db_path.exists() {
|
||||
return Err(format!(
|
||||
"No captured traffic yet: {} does not exist. Start Yaak Proxy to capture some.",
|
||||
db_path.display()
|
||||
));
|
||||
}
|
||||
let db = ProxyQueryManager::open_read_only(&db_path)
|
||||
.map_err(|e| format!("Failed to open {}: {e}", db_path.display()))?;
|
||||
let blobs = crate::db::open_blobs_read_only(&data_dir.join("blobs.sqlite"))
|
||||
.map_err(|e| format!("Failed to open the proxy's blob store: {e}"))?;
|
||||
let settings = ProxySettings::load(&data_dir.join("settings.json"))?;
|
||||
Ok(Self { db, blobs, capture_body_limit: settings.capture_body_limit })
|
||||
}
|
||||
|
||||
pub fn flows(&self, filter: &FlowFilter) -> Result<Vec<Flow>, String> {
|
||||
flows::list(&self.db, filter)
|
||||
}
|
||||
|
||||
pub async fn flow(&self, id: &str, body_limit: usize) -> Result<FlowDetail, String> {
|
||||
flows::detail(&self.db, &self.blobs, id, body_limit).await
|
||||
}
|
||||
|
||||
pub async fn body(&self, id: &str, which: FlowBody) -> Result<DecodedBody, String> {
|
||||
let flow = flows::get(&self.db, id)?;
|
||||
bodies::read_body(&self.blobs, &flow, which, self.capture_body_limit).await
|
||||
}
|
||||
|
||||
pub async fn har(&self, filter: &FlowFilter) -> Result<Har, String> {
|
||||
let flows = flows::list(&self.db, filter)?;
|
||||
har::export(&self.blobs, &flows, self.capture_body_limit).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
//! The control API: how the CLI, MCP tools and a second app launch reach the process that owns
|
||||
//! the engine. The owner serves JSON on `127.0.0.1:<random>` behind a bearer token and writes
|
||||
//! where to find it to `control.json` in the proxy data directory (mode 0600). One owner at a
|
||||
//! time: a file whose process is gone, or whose port no longer answers, is stale and replaced.
|
||||
|
||||
use crate::ca::{self, CaStatus};
|
||||
use crate::flows::{self, FlowFilter};
|
||||
use crate::har;
|
||||
use crate::macos::SystemProxyStatus;
|
||||
use crate::models::{FlowBody, find_header};
|
||||
use crate::save::SaveFlowRequest;
|
||||
use crate::{ProxyCtx, ProxyState, bodies};
|
||||
use bytes::Bytes;
|
||||
use chrono::{DateTime, Utc};
|
||||
use http_body_util::{BodyExt, Full, Limited};
|
||||
use hyper::body::Incoming;
|
||||
use hyper::header::{AUTHORIZATION, CONTENT_TYPE};
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use hyper_util::server::graceful::GracefulShutdown;
|
||||
use log::{info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::Write;
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc as std_mpsc;
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
use ts_rs::TS;
|
||||
use yaak_proxy::CaStore;
|
||||
|
||||
pub const CONTROL_FILE: &str = "control.json";
|
||||
const LOCK_FILE: &str = "control.lock";
|
||||
/// Body bytes `GET /flows/{id}` returns per side unless `bodyLimit` says otherwise.
|
||||
pub const DEFAULT_BODY_PREVIEW_LIMIT: usize = 1024 * 1024;
|
||||
const MAX_REQUEST_BODY: usize = 64 * 1024;
|
||||
const LIVENESS_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
const SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
|
||||
|
||||
/// The process that runs the engine.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub enum Owner {
|
||||
App,
|
||||
Cli,
|
||||
}
|
||||
|
||||
/// `control.json`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ControlInfo {
|
||||
pub pid: u32,
|
||||
pub control_port: u16,
|
||||
/// The engine's port while it runs.
|
||||
pub proxy_port: Option<u16>,
|
||||
pub token: String,
|
||||
pub owner: Owner,
|
||||
pub started_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl ControlInfo {
|
||||
pub fn base_url(&self) -> String {
|
||||
format!("http://127.0.0.1:{}", self.control_port)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn control_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join(CONTROL_FILE)
|
||||
}
|
||||
|
||||
/// `control.json` as it is, live or stale. `None` when there is none or it does not parse.
|
||||
pub fn read(data_dir: &Path) -> Option<ControlInfo> {
|
||||
let contents = std::fs::read_to_string(control_path(data_dir)).ok()?;
|
||||
serde_json::from_str(&contents).ok()
|
||||
}
|
||||
|
||||
/// Held while `control.json` is checked and then removed or claimed, across processes, so two
|
||||
/// of them cleaning up a stale file cannot delete each other's fresh claim. Released on drop.
|
||||
fn claim_lock(data_dir: &Path) -> std::io::Result<std::fs::File> {
|
||||
std::fs::create_dir_all(data_dir)?;
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(data_dir.join(LOCK_FILE))?;
|
||||
file.lock()?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// The owner running the engine now. A stale `control.json` is removed on the way, as is one
|
||||
/// that does not parse: it is only ever published whole, so that is never a claim in progress.
|
||||
pub fn live_owner(data_dir: &Path) -> Option<ControlInfo> {
|
||||
let _lock = claim_lock(data_dir)
|
||||
.inspect_err(|e| warn!("Failed to lock {LOCK_FILE}, checking without it: {e}"))
|
||||
.ok();
|
||||
live_owner_locked(data_dir)
|
||||
}
|
||||
|
||||
/// [`live_owner`] for a caller holding the claim lock.
|
||||
fn live_owner_locked(data_dir: &Path) -> Option<ControlInfo> {
|
||||
let path = control_path(data_dir);
|
||||
let contents = std::fs::read_to_string(&path).ok()?;
|
||||
let Ok(info) = serde_json::from_str::<ControlInfo>(&contents) else {
|
||||
warn!("Removing unreadable {}", path.display());
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return None;
|
||||
};
|
||||
if is_live(&info) {
|
||||
return Some(info);
|
||||
}
|
||||
info!("Removing stale {CONTROL_FILE} of {:?} pid {}", info.owner, info.pid);
|
||||
remove_if_token(data_dir, &info.token);
|
||||
None
|
||||
}
|
||||
|
||||
/// The process is alive and its control port answers. The port check catches a pid the OS
|
||||
/// has handed to some other process since the owner died.
|
||||
fn is_live(info: &ControlInfo) -> bool {
|
||||
pid_alive(info.pid)
|
||||
&& TcpStream::connect_timeout(
|
||||
&SocketAddr::from(([127, 0, 0, 1], info.control_port)),
|
||||
LIVENESS_TIMEOUT,
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn pid_alive(pid: u32) -> bool {
|
||||
let Ok(pid) = libc::pid_t::try_from(pid) else {
|
||||
return false;
|
||||
};
|
||||
// Signal 0 checks the process exists without signalling it; EPERM means it exists but is
|
||||
// someone else's.
|
||||
let alive = unsafe { libc::kill(pid, 0) } == 0;
|
||||
alive || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn pid_alive(_pid: u32) -> bool {
|
||||
// The control port check decides.
|
||||
true
|
||||
}
|
||||
|
||||
/// Removes `control.json` if it is still the one with `token`, so an owner never removes its
|
||||
/// successor's file. The caller holds the claim lock.
|
||||
fn remove_if_token(data_dir: &Path, token: &str) {
|
||||
if read(data_dir).is_some_and(|info| info.token == token) {
|
||||
let _ = std::fs::remove_file(control_path(data_dir));
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `info` to `path`, readable by its owner alone and never seen half-written. With
|
||||
/// `create_new`, fails when a file is already there, so two processes cannot both claim the
|
||||
/// engine.
|
||||
fn write_file(path: &Path, info: &ControlInfo, create_new: bool) -> std::io::Result<()> {
|
||||
let contents = serde_json::to_vec_pretty(info)?;
|
||||
let tmp = path.with_extension(format!("{}.tmp", std::process::id()));
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
options.open(&tmp)?.write_all(&contents)?;
|
||||
let published = if create_new {
|
||||
// A link, unlike a rename, refuses to replace an existing file.
|
||||
std::fs::hard_link(&tmp, path).and_then(|_| std::fs::remove_file(&tmp))
|
||||
} else {
|
||||
std::fs::rename(&tmp, path)
|
||||
};
|
||||
if published.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
published
|
||||
}
|
||||
|
||||
/// The owner's `control.json`, rewritten as the engine starts and stops.
|
||||
pub(crate) struct ControlFile {
|
||||
path: PathBuf,
|
||||
pub(crate) info: ControlInfo,
|
||||
}
|
||||
|
||||
impl ControlFile {
|
||||
pub(crate) fn set_proxy_port(&mut self, port: Option<u16>) {
|
||||
if self.info.proxy_port == port {
|
||||
return;
|
||||
}
|
||||
self.info.proxy_port = port;
|
||||
if let Err(e) = write_file(&self.path, &self.info, false) {
|
||||
warn!("Failed to update {}: {e}", self.path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Responses --
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ControlStatus {
|
||||
pub owner: Owner,
|
||||
pub pid: u32,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub state: ProxyState,
|
||||
/// The engine's port while it runs.
|
||||
pub proxy_port: Option<u16>,
|
||||
/// The port the engine starts on.
|
||||
pub configured_port: u16,
|
||||
/// Flows are stored. Paused, traffic still passes through, unrecorded.
|
||||
pub recording: bool,
|
||||
pub flow_count: usize,
|
||||
pub ca: CaStatus,
|
||||
/// `None` when it could not be read.
|
||||
pub system_proxy: Option<SystemProxyStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct StartRequest {
|
||||
/// For this run only; `settings.json` keeps its port.
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SystemProxyRequest {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecordingRequest {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClearResponse {
|
||||
/// How many flows were deleted.
|
||||
pub cleared: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ErrorBody {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
/// Raw body headers: whether the body is cut short, at the capture limit or on the wire.
|
||||
pub const TRUNCATED_HEADER: &str = "x-yaak-truncated";
|
||||
|
||||
// -- Server --
|
||||
|
||||
/// The control server of the process that owns the engine. Dropping it stops serving and
|
||||
/// removes `control.json`.
|
||||
pub struct ControlServer {
|
||||
info: ControlInfo,
|
||||
data_dir: PathBuf,
|
||||
ctx: ProxyCtx,
|
||||
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
stop_rx: Option<std_mpsc::Receiver<()>>,
|
||||
}
|
||||
|
||||
impl ControlServer {
|
||||
/// Claims the engine for this process and serves the control API for `ctx`. Fails when
|
||||
/// another live owner has it.
|
||||
pub fn start(ctx: ProxyCtx) -> Result<Self, String> {
|
||||
let data_dir = ctx.data_dir().to_path_buf();
|
||||
let path = control_path(&data_dir);
|
||||
if let Some(owner) = live_owner(&data_dir) {
|
||||
return Err(already_owned(&owner));
|
||||
}
|
||||
|
||||
let (ready_tx, ready_rx) = std_mpsc::channel();
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let (stop_tx, stop_rx) = std_mpsc::channel();
|
||||
let token = yaak_database::generate_id_of_length(40);
|
||||
let state = Arc::new(ServerState { ctx: ctx.clone(), token: token.clone(), stop_tx });
|
||||
|
||||
let thread = std::thread::spawn(move || {
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.thread_name("yaak-proxy-control")
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(format!("Failed to create runtime: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(async move {
|
||||
let listener = match TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await
|
||||
{
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(format!("Failed to bind control port: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = ready_tx
|
||||
.send(listener.local_addr().map(|a| a.port()).map_err(|e| e.to_string()));
|
||||
serve(listener, state, shutdown_rx).await;
|
||||
});
|
||||
});
|
||||
|
||||
let control_port = match ready_rx.recv() {
|
||||
Ok(Ok(port)) => port,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => return Err("Control thread died before binding".into()),
|
||||
};
|
||||
let info = ControlInfo {
|
||||
pid: std::process::id(),
|
||||
control_port,
|
||||
proxy_port: ctx.port(),
|
||||
token,
|
||||
owner: ctx.owner(),
|
||||
started_at: Utc::now(),
|
||||
};
|
||||
let mut server = Self {
|
||||
info: info.clone(),
|
||||
data_dir: data_dir.clone(),
|
||||
ctx: ctx.clone(),
|
||||
shutdown_tx: Some(shutdown_tx),
|
||||
thread: Some(thread),
|
||||
stop_rx: Some(stop_rx),
|
||||
};
|
||||
|
||||
// Checked again under the lock: another process may have claimed it since.
|
||||
let claimed = claim_lock(&data_dir)
|
||||
.map_err(|e| format!("Failed to lock {LOCK_FILE}: {e}"))
|
||||
.and_then(|_lock| match live_owner_locked(&data_dir) {
|
||||
Some(owner) => Err(already_owned(&owner)),
|
||||
None => write_file(&path, &info, true)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", path.display())),
|
||||
});
|
||||
if let Err(e) = claimed {
|
||||
// Nothing of this server's to remove.
|
||||
server.info.token.clear();
|
||||
return Err(e);
|
||||
}
|
||||
ctx.set_control_file(Some(ControlFile { path, info }));
|
||||
info!("Control API listening on 127.0.0.1:{control_port}");
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
pub fn info(&self) -> &ControlInfo {
|
||||
&self.info
|
||||
}
|
||||
|
||||
/// Receives once per `POST /stop`. A CLI daemon exits on it; the app only stops its engine.
|
||||
pub fn take_stop_rx(&mut self) -> Option<std_mpsc::Receiver<()>> {
|
||||
self.stop_rx.take()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn already_owned(owner: &ControlInfo) -> String {
|
||||
format!("The proxy is already running in {} (pid {})", owner_name(owner.owner), owner.pid)
|
||||
}
|
||||
|
||||
pub(crate) fn owner_name(owner: Owner) -> &'static str {
|
||||
match owner {
|
||||
Owner::App => "Yaak Proxy",
|
||||
Owner::Cli => "the yaak CLI",
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ControlServer {
|
||||
fn drop(&mut self) {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
if !self.info.token.is_empty() {
|
||||
self.ctx.set_control_file(None);
|
||||
let _lock = claim_lock(&self.data_dir).ok();
|
||||
remove_if_token(&self.data_dir, &self.info.token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerState {
|
||||
ctx: ProxyCtx,
|
||||
token: String,
|
||||
stop_tx: std_mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
listener: TcpListener,
|
||||
state: Arc<ServerState>,
|
||||
mut shutdown_rx: tokio::sync::oneshot::Receiver<()>,
|
||||
) {
|
||||
// Lets a request in flight at shutdown finish, such as the `POST /stop` a daemon exits on.
|
||||
let graceful = GracefulShutdown::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = listener.accept() => match result {
|
||||
Ok((stream, _)) => {
|
||||
let state = state.clone();
|
||||
let service = hyper::service::service_fn(move |req| {
|
||||
let state = state.clone();
|
||||
async move { Ok::<_, std::convert::Infallible>(handle(&state, req).await) }
|
||||
});
|
||||
let conn = hyper::server::conn::http1::Builder::new()
|
||||
.serve_connection(TokioIo::new(stream), service);
|
||||
let conn = graceful.watch(conn);
|
||||
tokio::spawn(async move {
|
||||
let _ = conn.await;
|
||||
});
|
||||
}
|
||||
Err(e) => warn!("Control accept error: {e}"),
|
||||
},
|
||||
_ = &mut shutdown_rx => break,
|
||||
}
|
||||
}
|
||||
drop(listener);
|
||||
if tokio::time::timeout(SHUTDOWN_GRACE, graceful.shutdown()).await.is_err() {
|
||||
warn!("Control connections were still open after {SHUTDOWN_GRACE:?}");
|
||||
}
|
||||
}
|
||||
|
||||
type ControlResponse = Response<Full<Bytes>>;
|
||||
|
||||
struct ApiError {
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
fn new(status: StatusCode, message: impl Into<String>) -> Self {
|
||||
Self { status, message: message.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ApiError {
|
||||
fn from(message: String) -> Self {
|
||||
Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle(state: &ServerState, req: Request<Incoming>) -> ControlResponse {
|
||||
if !authorized(&req, &state.token) {
|
||||
return error_response(ApiError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Missing or wrong bearer token; it is in control.json",
|
||||
));
|
||||
}
|
||||
match route(state, req).await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => error_response(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn authorized(req: &Request<Incoming>, token: &str) -> bool {
|
||||
let Some(given) = req
|
||||
.headers()
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
// Compares every byte, so the time taken says nothing about how much matched.
|
||||
given.len() == token.len()
|
||||
&& given.bytes().zip(token.bytes()).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0
|
||||
}
|
||||
|
||||
async fn route(state: &ServerState, req: Request<Incoming>) -> Result<ControlResponse, ApiError> {
|
||||
let ctx = &state.ctx;
|
||||
let method = req.method().clone();
|
||||
let path = req.uri().path().to_string();
|
||||
let query = req.uri().query().unwrap_or_default().to_string();
|
||||
let segments: Vec<&str> = path.trim_matches('/').split('/').collect();
|
||||
|
||||
match (&method, segments.as_slice()) {
|
||||
(&Method::GET, ["status"]) => json(&status(ctx).await?),
|
||||
(&Method::POST, ["start"]) => {
|
||||
let body: StartRequest = read_json(req).await?;
|
||||
ctx.start_on(body.port)?;
|
||||
json(&status(ctx).await?)
|
||||
}
|
||||
(&Method::POST, ["stop"]) => {
|
||||
ctx.stop()?;
|
||||
let _ = state.stop_tx.send(());
|
||||
json(&status(ctx).await?)
|
||||
}
|
||||
(&Method::POST, ["recording"]) => {
|
||||
let body: RecordingRequest = read_json(req).await?;
|
||||
ctx.set_recording(body.enabled);
|
||||
json(&status(ctx).await?)
|
||||
}
|
||||
(&Method::GET, ["flows"]) => {
|
||||
let filter = FlowFilter::from_query(&query).map_err(bad_request)?;
|
||||
json(&flows::list(&ctx.db, &filter)?)
|
||||
}
|
||||
(&Method::POST, ["flows", "clear"]) => {
|
||||
let ctx = ctx.clone();
|
||||
json(&ClearResponse { cleared: blocking(move || ctx.clear_flows()).await? })
|
||||
}
|
||||
(&Method::GET, ["flows", id]) => {
|
||||
found(ctx, id)?;
|
||||
let limit = body_limit(&query)?;
|
||||
json(&flows::detail(&ctx.db, &ctx.blobs, id, limit).await?)
|
||||
}
|
||||
(&Method::GET, ["flows", id, "request-body"]) => raw_body(ctx, id, FlowBody::Request).await,
|
||||
(&Method::GET, ["flows", id, "response-body"]) => {
|
||||
raw_body(ctx, id, FlowBody::Response).await
|
||||
}
|
||||
(&Method::POST, ["flows", id, "save"]) => {
|
||||
found(ctx, id)?;
|
||||
let id = id.to_string();
|
||||
let body: SaveFlowRequest = read_json(req).await?;
|
||||
json(&ctx.save_flow(&id, &body).await.map_err(bad_request)?)
|
||||
}
|
||||
(&Method::GET, ["har"]) => {
|
||||
let filter = FlowFilter::from_query(&query).map_err(bad_request)?;
|
||||
let flows = flows::list(&ctx.db, &filter)?;
|
||||
json(&har::export(&ctx.blobs, &flows, ctx.capture_body_limit()?).await?)
|
||||
}
|
||||
(&Method::GET, ["ca"]) => {
|
||||
let ca = CaStore::load_or_create(&ctx.ca_dir())
|
||||
.map_err(|e| format!("Failed to load proxy CA: {e}"))?;
|
||||
Ok(response(StatusCode::OK, "application/x-pem-file", ca.pem().to_string()))
|
||||
}
|
||||
(&Method::POST, ["ca", "trust"]) => {
|
||||
let ca = CaStore::load_or_create(&ctx.ca_dir())
|
||||
.map_err(|e| format!("Failed to load proxy CA: {e}"))?;
|
||||
ca::trust(ca.cert_path()).await.map_err(bad_request)?;
|
||||
ctx.forget_passthrough();
|
||||
json(&ca::status(ca.cert_path()).await?)
|
||||
}
|
||||
(&Method::GET, ["system-proxy"]) => {
|
||||
let ctx = ctx.clone();
|
||||
json(&blocking(move || ctx.system_proxy_status()).await?)
|
||||
}
|
||||
(&Method::POST, ["system-proxy"]) => {
|
||||
let body: SystemProxyRequest = read_json(req).await?;
|
||||
let ctx = ctx.clone();
|
||||
json(&blocking(move || ctx.set_system_proxy(body.enabled)).await.map_err(bad_request)?)
|
||||
}
|
||||
(&Method::GET, ["workspaces"]) => json(&ctx.workspaces().map_err(bad_request)?),
|
||||
_ => Err(ApiError::new(StatusCode::NOT_FOUND, format!("No route {method} {path}"))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn status(ctx: &ProxyCtx) -> Result<ControlStatus, ApiError> {
|
||||
let control =
|
||||
ctx.control_info().ok_or_else(|| "The control API is shutting down".to_string())?;
|
||||
let proxy_port = ctx.port();
|
||||
let ca = ca::status(&ctx.ca_dir().join("ca.pem")).await?;
|
||||
let system_proxy = {
|
||||
let ctx = ctx.clone();
|
||||
blocking(move || ctx.system_proxy_status()).await.ok()
|
||||
};
|
||||
Ok(ControlStatus {
|
||||
owner: control.owner,
|
||||
pid: control.pid,
|
||||
started_at: control.started_at,
|
||||
state: if proxy_port.is_some() { ProxyState::Running } else { ProxyState::Stopped },
|
||||
proxy_port,
|
||||
configured_port: ctx.settings()?.port,
|
||||
recording: ctx.recording(),
|
||||
flow_count: flows::count(&ctx.db)?,
|
||||
ca,
|
||||
system_proxy,
|
||||
})
|
||||
}
|
||||
|
||||
fn found(ctx: &ProxyCtx, id: &str) -> Result<(), ApiError> {
|
||||
match flows::find(&ctx.db, id) {
|
||||
Some(_) => Ok(()),
|
||||
None => Err(ApiError::new(StatusCode::NOT_FOUND, format!("No flow {id}"))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn raw_body(ctx: &ProxyCtx, id: &str, which: FlowBody) -> Result<ControlResponse, ApiError> {
|
||||
let flow = flows::find(&ctx.db, id)
|
||||
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, format!("No flow {id}")))?;
|
||||
let body = bodies::read_body(&ctx.blobs, &flow, which, ctx.capture_body_limit()?).await?;
|
||||
let content_type =
|
||||
find_header(flow.headers(which), "content-type").unwrap_or("application/octet-stream");
|
||||
let mut resp = response(StatusCode::OK, content_type, body.data);
|
||||
resp.headers_mut().insert(TRUNCATED_HEADER, body.truncated.to_string().parse().unwrap());
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
fn body_limit(query: &str) -> Result<usize, ApiError> {
|
||||
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
|
||||
if key == "bodyLimit" {
|
||||
return value.parse().map_err(|_| bad_request(format!("Invalid bodyLimit {value:?}")));
|
||||
}
|
||||
}
|
||||
Ok(DEFAULT_BODY_PREVIEW_LIMIT)
|
||||
}
|
||||
|
||||
async fn blocking<T: Send + 'static>(
|
||||
f: impl FnOnce() -> Result<T, String> + Send + 'static,
|
||||
) -> Result<T, String> {
|
||||
tokio::task::spawn_blocking(f).await.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
/// The JSON request body; an empty one reads as `{}`.
|
||||
async fn read_json<T: serde::de::DeserializeOwned>(req: Request<Incoming>) -> Result<T, ApiError> {
|
||||
let bytes = Limited::new(req.into_body(), MAX_REQUEST_BODY)
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| bad_request(format!("Failed to read the request body: {e}")))?
|
||||
.to_bytes();
|
||||
let bytes: &[u8] = if bytes.iter().all(u8::is_ascii_whitespace) { b"{}" } else { &bytes };
|
||||
serde_json::from_slice(bytes).map_err(|e| bad_request(format!("Invalid JSON body: {e}")))
|
||||
}
|
||||
|
||||
fn bad_request(message: String) -> ApiError {
|
||||
ApiError::new(StatusCode::BAD_REQUEST, message)
|
||||
}
|
||||
|
||||
fn json<T: Serialize>(value: &T) -> Result<ControlResponse, ApiError> {
|
||||
let body = serde_json::to_vec(value).map_err(|e| e.to_string())?;
|
||||
Ok(response(StatusCode::OK, "application/json", body))
|
||||
}
|
||||
|
||||
fn error_response(e: ApiError) -> ControlResponse {
|
||||
let body = serde_json::to_vec(&ErrorBody { error: e.message }).unwrap_or_default();
|
||||
response(e.status, "application/json", body)
|
||||
}
|
||||
|
||||
fn response(status: StatusCode, content_type: &str, body: impl Into<Bytes>) -> ControlResponse {
|
||||
let mut resp = Response::new(Full::new(body.into()));
|
||||
*resp.status_mut() = status;
|
||||
if let Ok(value) = content_type.parse() {
|
||||
resp.headers_mut().insert(CONTENT_TYPE, value);
|
||||
}
|
||||
resp
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
//! A captured flow as a Yaak request. Headers Yaak sets itself when sending, and hop-by-hop
|
||||
//! headers that only meant something between the app and the proxy, are dropped and listed so
|
||||
//! the save dialog can say why. Authorization is kept as the plain header it was captured as.
|
||||
|
||||
use crate::bodies::DecodedBody;
|
||||
use crate::models::{Flow, find_header};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
use ts_rs::TS;
|
||||
use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter};
|
||||
|
||||
// The client's body types (`apps/yaak-client/lib/model_util.ts`).
|
||||
const BODY_TYPE_JSON: &str = "application/json";
|
||||
const BODY_TYPE_XML: &str = "text/xml";
|
||||
const BODY_TYPE_GRAPHQL: &str = "graphql";
|
||||
const BODY_TYPE_FORM_URLENCODED: &str = "application/x-www-form-urlencoded";
|
||||
const BODY_TYPE_OTHER: &str = "other";
|
||||
|
||||
/// Removed between the app and the proxy; never forwarded as they are.
|
||||
const HOP_BY_HOP: &[&str] = &[
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
];
|
||||
/// Yaak works these out itself when it sends the request.
|
||||
const AUTOMATIC: &[&str] = &["host", "content-length", "accept-encoding"];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct DroppedHeader {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
pub reason: DropReason,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub enum DropReason {
|
||||
/// Only meant for the hop between the app and the proxy.
|
||||
HopByHop,
|
||||
/// Yaak sets it when it sends the request.
|
||||
SetBySending,
|
||||
/// The body is saved decoded, so its encoding no longer applies.
|
||||
BodyDecoded,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Conversion {
|
||||
pub request: HttpRequest,
|
||||
pub dropped_headers: Vec<DroppedHeader>,
|
||||
/// What the saved request does not carry over exactly, one sentence each.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// `flow` as a request in no workspace yet, with `body` its request body decoded.
|
||||
pub fn flow_to_request(flow: &Flow, body: &DecodedBody) -> Conversion {
|
||||
let mut warnings = Vec::new();
|
||||
let (url, url_parameters) = split_query(&flow.url);
|
||||
let (headers, dropped_headers) = split_headers(flow, body);
|
||||
let has_authorization = headers.iter().any(|h| h.name.eq_ignore_ascii_case("authorization"));
|
||||
if has_authorization {
|
||||
warnings.push("The Authorization header is saved as captured, in plain text.".into());
|
||||
}
|
||||
let (body_type, body_fields) = request_body(flow, body, &mut warnings);
|
||||
if body.truncated {
|
||||
warnings.push(format!(
|
||||
"The body was cut at the capture limit, so only its first {} bytes are saved.",
|
||||
body.data.len()
|
||||
));
|
||||
}
|
||||
|
||||
// No name, as in Yaak, which then shows the URL.
|
||||
let request = HttpRequest {
|
||||
method: flow.method.clone(),
|
||||
url,
|
||||
url_parameters,
|
||||
headers,
|
||||
body_type,
|
||||
body: body_fields,
|
||||
// No auth rather than inherited, so a folder's or workspace's auth cannot replace the
|
||||
// captured header when the request is sent.
|
||||
authentication_type: has_authorization.then(|| "none".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
Conversion { request, dropped_headers, warnings }
|
||||
}
|
||||
|
||||
/// The URL without its query, and the query as parameters.
|
||||
fn split_query(url: &str) -> (String, Vec<HttpUrlParameter>) {
|
||||
let (url, fragment) = match url.split_once('#') {
|
||||
Some((url, fragment)) => (url, Some(fragment)),
|
||||
None => (url, None),
|
||||
};
|
||||
let Some((base, query)) = url.split_once('?') else {
|
||||
return (url.to_string() + &fragment.map(|f| format!("#{f}")).unwrap_or_default(), vec![]);
|
||||
};
|
||||
let params = url::form_urlencoded::parse(query.as_bytes())
|
||||
.map(|(name, value)| HttpUrlParameter {
|
||||
enabled: true,
|
||||
name: name.into_owned(),
|
||||
value: value.into_owned(),
|
||||
id: None,
|
||||
})
|
||||
.collect();
|
||||
(base.to_string() + &fragment.map(|f| format!("#{f}")).unwrap_or_default(), params)
|
||||
}
|
||||
|
||||
fn split_headers(flow: &Flow, body: &DecodedBody) -> (Vec<HttpRequestHeader>, Vec<DroppedHeader>) {
|
||||
// Connection names further headers that are hop-by-hop for this request alone.
|
||||
let listed: Vec<String> = flow
|
||||
.request_headers
|
||||
.iter()
|
||||
.filter(|h| h.name.eq_ignore_ascii_case("connection"))
|
||||
.flat_map(|h| h.value.split(','))
|
||||
.map(|n| n.trim().to_ascii_lowercase())
|
||||
.filter(|n| !n.is_empty())
|
||||
.collect();
|
||||
let encoding = find_header(&flow.request_headers, "content-encoding");
|
||||
// A body that decodes to nothing was still decoded.
|
||||
let decoded = body.stored_size > 0
|
||||
&& encoding.is_some_and(|e| !e.trim().eq_ignore_ascii_case("identity"));
|
||||
|
||||
let mut kept = Vec::new();
|
||||
let mut dropped = Vec::new();
|
||||
for h in &flow.request_headers {
|
||||
let name = h.name.to_ascii_lowercase();
|
||||
let reason = if HOP_BY_HOP.contains(&name.as_str())
|
||||
|| name.starts_with("proxy-")
|
||||
|| name.starts_with(':')
|
||||
|| listed.contains(&name)
|
||||
{
|
||||
Some(DropReason::HopByHop)
|
||||
} else if AUTOMATIC.contains(&name.as_str()) {
|
||||
Some(DropReason::SetBySending)
|
||||
} else if name == "content-encoding" && decoded {
|
||||
Some(DropReason::BodyDecoded)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match reason {
|
||||
Some(reason) => {
|
||||
dropped.push(DroppedHeader { name: h.name.clone(), value: h.value.clone(), reason })
|
||||
}
|
||||
None => kept.push(HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: h.name.clone(),
|
||||
value: h.value.clone(),
|
||||
id: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
(kept, dropped)
|
||||
}
|
||||
|
||||
fn request_body(
|
||||
flow: &Flow,
|
||||
body: &DecodedBody,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> (Option<String>, BTreeMap<String, Value>) {
|
||||
if body.data.is_empty() {
|
||||
return (None, BTreeMap::new());
|
||||
}
|
||||
let content_type = find_header(&flow.request_headers, "content-type").unwrap_or_default();
|
||||
let mime = content_type.split(';').next().unwrap_or_default().trim().to_ascii_lowercase();
|
||||
let Ok(text) = std::str::from_utf8(&body.data) else {
|
||||
warnings.push(
|
||||
"The body is binary and is saved as text, so bytes that are not UTF-8 are replaced."
|
||||
.into(),
|
||||
);
|
||||
return text_body(BODY_TYPE_OTHER, &String::from_utf8_lossy(&body.data));
|
||||
};
|
||||
|
||||
if mime == "application/json" || mime.ends_with("+json") {
|
||||
if let Some(graphql) = graphql_body(flow, text) {
|
||||
return (Some(BODY_TYPE_GRAPHQL.into()), graphql);
|
||||
}
|
||||
return text_body(BODY_TYPE_JSON, text);
|
||||
}
|
||||
if mime == "application/xml" || mime == "text/xml" || mime.ends_with("+xml") {
|
||||
return text_body(BODY_TYPE_XML, text);
|
||||
}
|
||||
if mime == BODY_TYPE_FORM_URLENCODED {
|
||||
let form: Vec<Value> = url::form_urlencoded::parse(text.as_bytes())
|
||||
.map(|(name, value)| json!({ "enabled": true, "name": name, "value": value }))
|
||||
.collect();
|
||||
return (
|
||||
Some(BODY_TYPE_FORM_URLENCODED.into()),
|
||||
BTreeMap::from([("form".into(), json!(form))]),
|
||||
);
|
||||
}
|
||||
if mime == "multipart/form-data" {
|
||||
warnings.push(
|
||||
"The multipart body is saved as text with its Content-Type, not as form fields.".into(),
|
||||
);
|
||||
}
|
||||
text_body(BODY_TYPE_OTHER, text)
|
||||
}
|
||||
|
||||
fn text_body(body_type: &str, text: &str) -> (Option<String>, BTreeMap<String, Value>) {
|
||||
(Some(body_type.into()), BTreeMap::from([("text".into(), json!(text))]))
|
||||
}
|
||||
|
||||
/// A JSON body posted to a GraphQL endpoint, as the client's GraphQL body.
|
||||
fn graphql_body(flow: &Flow, text: &str) -> Option<BTreeMap<String, Value>> {
|
||||
let path = flow.url.split('?').next().unwrap_or_default();
|
||||
if !path.to_ascii_lowercase().ends_with("/graphql") {
|
||||
return None;
|
||||
}
|
||||
let Value::Object(body) = serde_json::from_str::<Value>(text).ok()? else {
|
||||
return None;
|
||||
};
|
||||
// Anything else, such as persisted-query `extensions`, would be lost in a GraphQL body.
|
||||
if body.keys().any(|k| !matches!(k.as_str(), "query" | "variables" | "operationName")) {
|
||||
return None;
|
||||
}
|
||||
let query = body.get("query")?.as_str()?;
|
||||
let mut fields = BTreeMap::from([("query".to_string(), json!(query))]);
|
||||
if let Some(variables) = body.get("variables").filter(|v| !v.is_null()) {
|
||||
let pretty = serde_json::to_string_pretty(variables).ok()?;
|
||||
fields.insert("variables".into(), json!(pretty));
|
||||
}
|
||||
if let Some(name) = body.get("operationName").and_then(Value::as_str) {
|
||||
fields.insert("operationName".into(), json!(name));
|
||||
}
|
||||
Some(fields)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::ProxyHeader;
|
||||
|
||||
fn flow(method: &str, url: &str, headers: &[(&str, &str)]) -> Flow {
|
||||
Flow {
|
||||
method: method.into(),
|
||||
url: url.into(),
|
||||
request_headers: headers
|
||||
.iter()
|
||||
.map(|(n, v)| ProxyHeader { name: (*n).into(), value: (*v).into() })
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn body(data: &str) -> DecodedBody {
|
||||
DecodedBody {
|
||||
data: data.as_bytes().to_vec(),
|
||||
stored_size: data.len(),
|
||||
truncated: false,
|
||||
decode_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn names(request: &HttpRequest) -> Vec<&str> {
|
||||
request.headers.iter().map(|h| h.name.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_without_body() {
|
||||
let f = flow(
|
||||
"GET",
|
||||
"https://api.github.com/search/issues?q=is%3Aopen+label%3Abug&per_page=5",
|
||||
&[
|
||||
("Host", "api.github.com"),
|
||||
("Accept", "application/json"),
|
||||
("Accept-Encoding", "gzip, br"),
|
||||
("Connection", "keep-alive, X-Debug"),
|
||||
("X-Debug", "1"),
|
||||
("Proxy-Authorization", "Basic Zm9v"),
|
||||
("Authorization", "Bearer ghp_secret"),
|
||||
],
|
||||
);
|
||||
let c = flow_to_request(&f, &body(""));
|
||||
let r = &c.request;
|
||||
assert_eq!(r.method, "GET");
|
||||
assert_eq!(r.url, "https://api.github.com/search/issues");
|
||||
assert_eq!(r.name, "");
|
||||
let params: Vec<(&str, &str)> =
|
||||
r.url_parameters.iter().map(|p| (p.name.as_str(), p.value.as_str())).collect();
|
||||
assert_eq!(params, vec![("q", "is:open label:bug"), ("per_page", "5")]);
|
||||
assert_eq!(names(r), vec!["Accept", "Authorization"]);
|
||||
assert_eq!(r.headers[1].value, "Bearer ghp_secret");
|
||||
assert_eq!(r.authentication_type.as_deref(), Some("none"));
|
||||
assert_eq!(r.body_type, None);
|
||||
assert!(r.body.is_empty());
|
||||
|
||||
let dropped: Vec<(&str, DropReason)> =
|
||||
c.dropped_headers.iter().map(|d| (d.name.as_str(), d.reason)).collect();
|
||||
assert_eq!(
|
||||
dropped,
|
||||
vec![
|
||||
("Host", DropReason::SetBySending),
|
||||
("Accept-Encoding", DropReason::SetBySending),
|
||||
("Connection", DropReason::HopByHop),
|
||||
("X-Debug", DropReason::HopByHop),
|
||||
("Proxy-Authorization", DropReason::HopByHop),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
c.warnings,
|
||||
vec!["The Authorization header is saved as captured, in plain text."]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_body() {
|
||||
let f = flow(
|
||||
"POST",
|
||||
"https://api.stripe.com/v1/charges",
|
||||
&[
|
||||
("Content-Type", "application/json; charset=utf-8"),
|
||||
("Content-Length", "13"),
|
||||
],
|
||||
);
|
||||
let c = flow_to_request(&f, &body(r#"{"amount":42}"#));
|
||||
assert_eq!(c.request.body_type.as_deref(), Some("application/json"));
|
||||
assert_eq!(c.request.body["text"], r#"{"amount":42}"#);
|
||||
assert_eq!(names(&c.request), vec!["Content-Type"]);
|
||||
assert!(c.warnings.is_empty());
|
||||
// Without a captured Authorization, the request inherits its folder's auth.
|
||||
assert_eq!(c.request.authentication_type, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vendor_json_body() {
|
||||
let f = flow("PATCH", "https://x.test/a", &[("Content-Type", "application/vnd.api+json")]);
|
||||
let c = flow_to_request(&f, &body("{}"));
|
||||
assert_eq!(c.request.body_type.as_deref(), Some("application/json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphql_body() {
|
||||
let f =
|
||||
flow("POST", "https://api.github.com/graphql", &[("Content-Type", "application/json")]);
|
||||
let c = flow_to_request(
|
||||
&f,
|
||||
&body(
|
||||
r#"{"query":"query($n:Int){viewer{login}}","variables":{"n":1},"operationName":"Me"}"#,
|
||||
),
|
||||
);
|
||||
assert_eq!(c.request.body_type.as_deref(), Some("graphql"));
|
||||
assert_eq!(c.request.body["query"], "query($n:Int){viewer{login}}");
|
||||
assert_eq!(c.request.body["variables"], "{\n \"n\": 1\n}");
|
||||
assert_eq!(c.request.body["operationName"], "Me");
|
||||
|
||||
// Fields a GraphQL body cannot hold keep it plain JSON.
|
||||
let persisted = r#"{"query":"q","extensions":{"persistedQuery":{"version":1}}}"#;
|
||||
let c = flow_to_request(&f, &body(persisted));
|
||||
assert_eq!(c.request.body_type.as_deref(), Some("application/json"));
|
||||
|
||||
// The same body anywhere else is plain JSON.
|
||||
let f = flow("POST", "https://x.test/api", &[("Content-Type", "application/json")]);
|
||||
let c = flow_to_request(&f, &body(r#"{"query":"q"}"#));
|
||||
assert_eq!(c.request.body_type.as_deref(), Some("application/json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_body() {
|
||||
let f = flow("POST", "https://x.test/soap", &[("Content-Type", "application/soap+xml")]);
|
||||
let c = flow_to_request(&f, &body("<a/>"));
|
||||
assert_eq!(c.request.body_type.as_deref(), Some("text/xml"));
|
||||
assert_eq!(c.request.body["text"], "<a/>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_body() {
|
||||
let f = flow(
|
||||
"POST",
|
||||
"http://localhost:3000/login",
|
||||
&[("Content-Type", "application/x-www-form-urlencoded")],
|
||||
);
|
||||
let c = flow_to_request(&f, &body("user=greg¬e=hello+world%21"));
|
||||
assert_eq!(c.request.body_type.as_deref(), Some("application/x-www-form-urlencoded"));
|
||||
assert_eq!(
|
||||
c.request.body["form"],
|
||||
json!([
|
||||
{ "enabled": true, "name": "user", "value": "greg" },
|
||||
{ "enabled": true, "name": "note", "value": "hello world!" },
|
||||
])
|
||||
);
|
||||
assert_eq!(c.request.name, "", "unnamed, so Yaak shows its URL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_body() {
|
||||
let content_type = "multipart/form-data; boundary=XyZ";
|
||||
let f = flow("POST", "https://x.test/upload", &[("Content-Type", content_type)]);
|
||||
let text = "--XyZ\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\n1\r\n--XyZ--\r\n";
|
||||
let c = flow_to_request(&f, &body(text));
|
||||
assert_eq!(c.request.body_type.as_deref(), Some("other"));
|
||||
assert_eq!(c.request.body["text"], text);
|
||||
assert_eq!(c.request.headers[0].value, content_type);
|
||||
assert_eq!(c.warnings.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_body() {
|
||||
let f = flow("PUT", "https://x.test/", &[("Content-Type", "text/plain")]);
|
||||
let c = flow_to_request(&f, &body("hello"));
|
||||
assert_eq!(c.request.body_type.as_deref(), Some("other"));
|
||||
assert_eq!(c.request.body["text"], "hello");
|
||||
assert_eq!(c.request.name, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_body() {
|
||||
let f = flow("POST", "https://x.test/bin", &[("Content-Type", "application/octet-stream")]);
|
||||
let data = DecodedBody {
|
||||
data: vec![b'a', 0xff, b'b'],
|
||||
stored_size: 3,
|
||||
truncated: false,
|
||||
decode_error: None,
|
||||
};
|
||||
let c = flow_to_request(&f, &data);
|
||||
assert_eq!(c.request.body_type.as_deref(), Some("other"));
|
||||
assert_eq!(c.request.body["text"], "a\u{fffd}b");
|
||||
assert_eq!(c.warnings.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_empty_body() {
|
||||
let f = flow("POST", "https://x.test/", &[("Content-Encoding", "gzip")]);
|
||||
// A gzip stream of nothing: stored bytes, but an empty body once decoded.
|
||||
let data =
|
||||
DecodedBody { data: Vec::new(), stored_size: 20, truncated: false, decode_error: None };
|
||||
let c = flow_to_request(&f, &data);
|
||||
assert!(c.request.headers.is_empty());
|
||||
assert_eq!(c.dropped_headers[0].reason, DropReason::BodyDecoded);
|
||||
assert_eq!(c.request.body_type, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_and_truncated_body() {
|
||||
let f = flow(
|
||||
"POST",
|
||||
"https://x.test/logs",
|
||||
&[
|
||||
("Content-Type", "application/json"),
|
||||
("Content-Encoding", "gzip"),
|
||||
],
|
||||
);
|
||||
let data = DecodedBody {
|
||||
data: b"[1,2".to_vec(),
|
||||
stored_size: 4,
|
||||
truncated: true,
|
||||
decode_error: None,
|
||||
};
|
||||
let c = flow_to_request(&f, &data);
|
||||
assert_eq!(names(&c.request), vec!["Content-Type"]);
|
||||
assert_eq!(c.dropped_headers[0].reason, DropReason::BodyDecoded);
|
||||
assert_eq!(
|
||||
c.warnings,
|
||||
vec!["The body was cut at the capture limit, so only its first 4 bytes are saved."]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
use include_dir::{Dir, include_dir};
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use rusqlite::{Transaction, TransactionBehavior};
|
||||
use rusqlite::functions::FunctionFlags;
|
||||
use rusqlite::{OpenFlags, Transaction, TransactionBehavior};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use yaak_database::{ConnectionOrTx, DbContext, Error, Result, SqlitePool, run_migrations};
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
|
||||
static MIGRATIONS: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/migrations");
|
||||
|
||||
@@ -26,6 +28,13 @@ impl ProxyQueryManager {
|
||||
Ok(Self { readers: file_pool(db_path, 5)?, writer })
|
||||
}
|
||||
|
||||
/// `proxy.db` for reading while another process may be writing it, or none is. Writes
|
||||
/// fail. No migrations run, so an older database reads as it is.
|
||||
pub fn open_read_only(db_path: &Path) -> Result<Self> {
|
||||
let readers = read_only_pool(db_path)?;
|
||||
Ok(Self { writer: readers.clone(), readers })
|
||||
}
|
||||
|
||||
pub fn with_conn<F, T>(&self, func: F) -> T
|
||||
where
|
||||
F: FnOnce(&DbContext) -> T,
|
||||
@@ -62,11 +71,42 @@ impl ProxyQueryManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// `blobs.sqlite` for reading, alongside [`ProxyQueryManager::open_read_only`].
|
||||
pub fn open_blobs_read_only(path: &Path) -> Result<BlobManager> {
|
||||
let readers = read_only_pool(path)?;
|
||||
Ok(BlobManager::new(readers.clone(), readers))
|
||||
}
|
||||
|
||||
/// `sidebar_path(url)`, the app sidebar's place for a URL, for filtering by its nodes.
|
||||
fn add_functions(conn: &mut rusqlite::Connection) -> rusqlite::Result<()> {
|
||||
conn.create_scalar_function(
|
||||
"sidebar_path",
|
||||
1,
|
||||
FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC,
|
||||
|ctx| Ok(crate::flows::sidebar_path(&ctx.get::<String>(0)?)),
|
||||
)
|
||||
}
|
||||
|
||||
fn read_only_pool(path: &Path) -> Result<SqlitePool> {
|
||||
let manager = SqliteConnectionManager::file(path)
|
||||
.with_flags(OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX)
|
||||
.with_init(|conn| {
|
||||
conn.busy_timeout(Duration::from_millis(5000))?;
|
||||
add_functions(conn)
|
||||
});
|
||||
Pool::builder()
|
||||
.max_size(2)
|
||||
.connection_timeout(Duration::from_secs(10))
|
||||
.build(manager)
|
||||
.map_err(|e| Error::Database(e.to_string()))
|
||||
}
|
||||
|
||||
fn file_pool(path: &Path, max_size: u32) -> Result<SqlitePool> {
|
||||
let manager = SqliteConnectionManager::file(path).with_init(|conn| {
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
conn.busy_timeout(Duration::from_millis(5000))
|
||||
conn.busy_timeout(Duration::from_millis(5000))?;
|
||||
add_functions(conn)
|
||||
});
|
||||
Pool::builder()
|
||||
.max_size(max_size)
|
||||
@@ -85,6 +125,35 @@ mod tests {
|
||||
stmt.query_map([], |r| r.get::<_, String>("name")).unwrap().map(|c| c.unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_flows_read_only_before_migrations() {
|
||||
use crate::models::Flow;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("proxy.db");
|
||||
drop(ProxyQueryManager::new(&path).unwrap());
|
||||
|
||||
// As a build from before the saved request and client columns left it.
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE flows DROP COLUMN saved_request_id;
|
||||
ALTER TABLE flows DROP COLUMN client_pid;
|
||||
ALTER TABLE flows DROP COLUMN client_name;
|
||||
ALTER TABLE flows DROP COLUMN client_bundle_id;
|
||||
ALTER TABLE flows DROP COLUMN client_path;
|
||||
ALTER TABLE flows DROP COLUMN cancelled;
|
||||
INSERT INTO flows (id, url, method) VALUES ('fl_old', 'https://example.com', 'GET');",
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let db = ProxyQueryManager::open_read_only(&path).unwrap();
|
||||
let flows = db.with_conn(|c| c.find_all::<Flow>()).unwrap();
|
||||
assert_eq!(flows.len(), 1);
|
||||
assert_eq!((flows[0].id.as_str(), flows[0].saved_request_id.as_deref()), ("fl_old", None));
|
||||
assert_eq!((flows[0].client_pid, flows[0].client_name.as_deref()), (None, None));
|
||||
assert!(!flows[0].cancelled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrates_an_old_schema_db() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -122,10 +191,26 @@ mod tests {
|
||||
|
||||
let flow_columns = columns(&conn, "flows");
|
||||
assert!(flow_columns.contains(&"response_truncated".to_string()));
|
||||
for column in [
|
||||
"client_pid",
|
||||
"client_name",
|
||||
"client_bundle_id",
|
||||
"client_path",
|
||||
"cancelled",
|
||||
] {
|
||||
assert!(flow_columns.contains(&column.to_string()), "{column} in {flow_columns:?}");
|
||||
}
|
||||
let indexes: Vec<String> = conn
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'flows' AND sql IS NOT NULL")
|
||||
.unwrap()
|
||||
.query_map([], |r| r.get(0))
|
||||
.unwrap()
|
||||
.map(|i| i.unwrap())
|
||||
.collect();
|
||||
assert_eq!(indexes, vec!["idx_flows_started_at_id"]);
|
||||
assert!(!flow_columns.iter().any(|c| c.contains("body")), "{flow_columns:?}");
|
||||
|
||||
let journal_mode: String =
|
||||
conn.query_row("PRAGMA journal_mode", [], |r| r.get(0)).unwrap();
|
||||
let journal_mode: String = conn.query_row("PRAGMA journal_mode", [], |r| r.get(0)).unwrap();
|
||||
assert_eq!(journal_mode, "wal");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,795 @@
|
||||
use crate::bodies;
|
||||
use crate::db::ProxyQueryManager;
|
||||
use crate::models::{Flow, FlowBody, FlowIden};
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use rusqlite::types::Value;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use yaak_database::UpsertModelInfo;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
|
||||
/// Which flows a listing or a HAR export covers. Every field narrows it; none is all of them.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct FlowFilter {
|
||||
/// Search terms, any case, each of which must match the URL (host, path or query), the
|
||||
/// method, the status (`404`, `40`, `4xx`) or the content type: `stripe post 4xx`.
|
||||
#[ts(optional)]
|
||||
pub q: Option<String>,
|
||||
/// Part of the host, any case: `stripe` matches `api.stripe.com`.
|
||||
#[ts(optional)]
|
||||
pub host: Option<String>,
|
||||
/// Only flows under one of these hosts or paths, as the app's sidebar names them:
|
||||
/// `api.stripe.com` or `api.stripe.com/v1/charges`, with any port after the host.
|
||||
#[ts(optional)]
|
||||
pub under: Option<Vec<String>>,
|
||||
/// Only flows from one of these apps, by bundle id, or by path for a process outside an
|
||||
/// app bundle.
|
||||
#[ts(optional)]
|
||||
pub app: Option<Vec<String>>,
|
||||
/// The method, any case.
|
||||
#[ts(optional)]
|
||||
pub method: Option<String>,
|
||||
/// A status (`404`), a class of them (`4xx`), or `error` for flows that failed.
|
||||
#[ts(optional)]
|
||||
pub status: Option<String>,
|
||||
/// At most this many flows, the newest.
|
||||
#[ts(optional, type = "number")]
|
||||
pub limit: Option<usize>,
|
||||
/// Only flows recorded after the flow with this id, for following new traffic. Flows are
|
||||
/// recorded when they end, so a slow one that started earlier still comes through.
|
||||
#[ts(optional)]
|
||||
pub after: Option<String>,
|
||||
/// Only flows older than the flow with this id, by start and then id, for paging back
|
||||
/// through older traffic.
|
||||
#[ts(optional)]
|
||||
pub before: Option<String>,
|
||||
}
|
||||
|
||||
impl FlowFilter {
|
||||
/// The filter from a query string's `q`, `host`, `under`, `app`, `method`, `status`,
|
||||
/// `limit`, `after` and `before`. `under` and `app` may repeat.
|
||||
pub fn from_query(query: &str) -> Result<Self, String> {
|
||||
let mut filter = Self::default();
|
||||
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
|
||||
let value = Some(value.into_owned()).filter(|v| !v.is_empty());
|
||||
match key.as_ref() {
|
||||
"q" => filter.q = value,
|
||||
"host" => filter.host = value,
|
||||
"under" => filter.under.get_or_insert_default().extend(value),
|
||||
"app" => filter.app.get_or_insert_default().extend(value),
|
||||
"method" => filter.method = value,
|
||||
"status" => filter.status = value,
|
||||
"after" => filter.after = value,
|
||||
"before" => filter.before = value,
|
||||
"limit" => {
|
||||
filter.limit = value
|
||||
.map(|v| v.parse().map_err(|_| format!("Invalid limit {v:?}")))
|
||||
.transpose()?
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(filter)
|
||||
}
|
||||
|
||||
pub fn to_query(&self) -> String {
|
||||
let mut query = url::form_urlencoded::Serializer::new(String::new());
|
||||
let limit = self.limit.map(|l| l.to_string());
|
||||
for (key, value) in [("q", &self.q), ("host", &self.host)] {
|
||||
if let Some(value) = value {
|
||||
query.append_pair(key, value);
|
||||
}
|
||||
}
|
||||
for (key, values) in [("under", &self.under), ("app", &self.app)] {
|
||||
for value in values.iter().flatten() {
|
||||
query.append_pair(key, value);
|
||||
}
|
||||
}
|
||||
for (key, value) in [
|
||||
("method", &self.method),
|
||||
("status", &self.status),
|
||||
("limit", &limit),
|
||||
("after", &self.after),
|
||||
("before", &self.before),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
query.append_pair(key, value);
|
||||
}
|
||||
}
|
||||
query.finish()
|
||||
}
|
||||
|
||||
/// Narrows more than `after`, `before` and `limit` do, so it covers fewer than all flows.
|
||||
pub fn narrows(&self) -> bool {
|
||||
let given = |v: &Option<String>| v.as_deref().is_some_and(|v| !v.trim().is_empty());
|
||||
given(&self.q)
|
||||
|| given(&self.host)
|
||||
|| given(&self.method)
|
||||
|| given(&self.status)
|
||||
|| self.under.is_some()
|
||||
|| self.app.is_some()
|
||||
}
|
||||
|
||||
/// The SQL condition for every field but `after`, `before` and `limit`, and its parameters.
|
||||
fn condition(&self) -> (String, Vec<Value>) {
|
||||
let mut sql = String::from("1");
|
||||
let mut params: Vec<Value> = Vec::new();
|
||||
for term in self.q.iter().flat_map(|q| q.split_whitespace()) {
|
||||
let term = term.to_lowercase();
|
||||
sql.push_str(
|
||||
" AND (instr(lower(url), ?) > 0 OR lower(method) = ? \
|
||||
OR instr(lower(content_type), ?) > 0",
|
||||
);
|
||||
params.extend([
|
||||
term.clone().into(),
|
||||
term.clone().into(),
|
||||
term.clone().into(),
|
||||
]);
|
||||
if let Some(status) = status_condition(&term, &mut params) {
|
||||
sql.push_str(" OR ");
|
||||
sql.push_str(&status);
|
||||
}
|
||||
sql.push(')');
|
||||
}
|
||||
if let Some(host) = self.host.as_deref().filter(|h| !h.is_empty()) {
|
||||
sql.push_str(&format!(" AND instr(lower({HOST_SQL}), ?) > 0"));
|
||||
params.push(host.to_lowercase().into());
|
||||
}
|
||||
if let Some(under) = &self.under {
|
||||
// The node itself, or anything below it, by the sidebar's rules.
|
||||
let mut any = Vec::new();
|
||||
for node in under {
|
||||
let node = normalize_node(node);
|
||||
any.push("(sidebar_path(url) = ? OR substr(sidebar_path(url), 1, ?) = ?)");
|
||||
let below = format!("{node}/");
|
||||
params.extend([
|
||||
node.into(),
|
||||
(below.chars().count() as i64).into(),
|
||||
below.into(),
|
||||
]);
|
||||
}
|
||||
let any = if any.is_empty() { "0".to_string() } else { any.join(" OR ") };
|
||||
sql.push_str(&format!(" AND ({any})"));
|
||||
}
|
||||
if let Some(apps) = &self.app {
|
||||
let marks = vec!["?"; apps.len()].join(", ");
|
||||
sql.push_str(&format!(" AND coalesce(client_bundle_id, client_path) IN ({marks})"));
|
||||
params.extend(apps.iter().map(|a| a.clone().into()));
|
||||
}
|
||||
if let Some(method) = self.method.as_deref().filter(|m| !m.is_empty()) {
|
||||
sql.push_str(" AND lower(method) = ?");
|
||||
params.push(method.to_lowercase().into());
|
||||
}
|
||||
match self.status.as_deref().map(str::trim) {
|
||||
None | Some("") => {}
|
||||
Some(s) if s.eq_ignore_ascii_case("error") => sql.push_str(" AND error IS NOT NULL"),
|
||||
// A whole status or a class, not the start of one as a search term may be.
|
||||
Some(s) if s.len() == 3 => match status_condition(&s.to_ascii_lowercase(), &mut params)
|
||||
{
|
||||
Some(status) => {
|
||||
sql.push_str(" AND ");
|
||||
sql.push_str(&status);
|
||||
}
|
||||
None => sql.push_str(" AND 0"),
|
||||
},
|
||||
Some(_) => sql.push_str(" AND 0"),
|
||||
}
|
||||
(sql, params)
|
||||
}
|
||||
}
|
||||
|
||||
/// The host and port of `url`, in SQL: what follows `://` up to the first `/`, `?` or `#`.
|
||||
const HOST_SQL: &str = "(SELECT CASE WHEN instr(r, '/') > 0 THEN substr(r, 1, instr(r, '/') - 1) \
|
||||
ELSE r END FROM (SELECT replace(replace(CASE WHEN instr(url, '://') > 0 \
|
||||
THEN substr(url, instr(url, '://') + 3) ELSE url END, '?', '/'), '#', '/') AS r))";
|
||||
|
||||
/// Where the app's sidebar places a URL: its host, lowercased and without its scheme's default
|
||||
/// port, then its path's non-empty segments, joined by `/`, as in `api.stripe.com/v1/charges`.
|
||||
/// A URL without a scheme is a host of its own. `sidebarPath` in the app's `lib/search.ts`
|
||||
/// follows the same rules, and the sidebar's nodes are built from it.
|
||||
pub fn sidebar_path(url: &str) -> String {
|
||||
let Some((scheme, rest)) = url.split_once("://") else {
|
||||
return url.to_string();
|
||||
};
|
||||
let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
|
||||
let (authority, rest) = rest.split_at(end);
|
||||
let mut host = authority.to_ascii_lowercase();
|
||||
let default_port = match scheme.to_ascii_lowercase().as_str() {
|
||||
"http" | "ws" => Some(":80"),
|
||||
"https" | "wss" => Some(":443"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(port) = default_port
|
||||
&& host.ends_with(port)
|
||||
{
|
||||
host.truncate(host.len() - port.len());
|
||||
}
|
||||
let path = rest.split(['?', '#']).next().unwrap_or_default();
|
||||
let mut key = host;
|
||||
for segment in path.split('/').filter(|s| !s.is_empty()) {
|
||||
key.push('/');
|
||||
key.push_str(segment);
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
/// A sidebar node as `sidebar_path` writes it, its host lowercased.
|
||||
fn normalize_node(node: &str) -> String {
|
||||
match node.split_once('/') {
|
||||
Some((host, path)) => format!("{}/{path}", host.to_ascii_lowercase()),
|
||||
None => node.to_ascii_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The SQL condition for a status term: `404` exactly, `40` as the start of one, or a class
|
||||
/// such as `4xx`. None when the term is not one.
|
||||
fn status_condition(term: &str, params: &mut Vec<Value>) -> Option<String> {
|
||||
if let Some(class) = term.strip_suffix("xx")
|
||||
&& let Ok(class @ 1..=9) = class.parse::<i64>()
|
||||
{
|
||||
params.push(class.into());
|
||||
return Some("status / 100 = ?".into());
|
||||
}
|
||||
if term.is_empty() || term.len() > 3 || !term.chars().all(|c| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
if term.len() == 3 {
|
||||
params.push(term.parse::<i64>().ok()?.into());
|
||||
return Some("status = ?".into());
|
||||
}
|
||||
params.push(format!("{term}%").into());
|
||||
Some("CAST(status AS TEXT) LIKE ?".into())
|
||||
}
|
||||
|
||||
/// The flows `filter` covers, newest first: by start, then by id. Reads the table in that order
|
||||
/// through its index and stops at the limit, so a page of a common match costs the same however
|
||||
/// many flows there are.
|
||||
pub fn list(db: &ProxyQueryManager, filter: &FlowFilter) -> Result<Vec<Flow>, String> {
|
||||
db.with_conn(|c| list_in(c.conn().resolve(), filter))
|
||||
}
|
||||
|
||||
/// [`list`] and the count of every flow, read from one snapshot of the database, so the count
|
||||
/// takes in exactly the flows written before the page was read. With a filter that narrows,
|
||||
/// also the count of the flows it covers, whatever the limit.
|
||||
pub fn page(
|
||||
db: &ProxyQueryManager,
|
||||
filter: &FlowFilter,
|
||||
) -> Result<(Vec<Flow>, usize, Option<usize>), String> {
|
||||
db.with_conn(|c| {
|
||||
let tx = c.conn().resolve().unchecked_transaction().map_err(|e| e.to_string())?;
|
||||
let flows = list_in(&tx, filter)?;
|
||||
let total = count_in(&tx)?;
|
||||
let matching = if filter.narrows() { Some(count_matching(&tx, filter)?) } else { None };
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
Ok((flows, total, matching))
|
||||
})
|
||||
}
|
||||
|
||||
fn list_in(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result<Vec<Flow>, String> {
|
||||
let (condition, mut params) = filter.condition();
|
||||
let mut sql = format!("SELECT * FROM flows WHERE {condition}");
|
||||
if let Some(id) = &filter.after {
|
||||
// By rowid, which only grows as flows are written.
|
||||
let rowid: i64 = conn
|
||||
.query_row("SELECT rowid FROM flows WHERE id = ?", [id], |r| r.get(0))
|
||||
.map_err(|_| format!("No flow {id}"))?;
|
||||
sql.push_str(" AND rowid > ?");
|
||||
params.push(rowid.into());
|
||||
}
|
||||
if let Some(id) = &filter.before {
|
||||
let started_at: Value = conn
|
||||
.query_row("SELECT started_at FROM flows WHERE id = ?", [id], |r| r.get(0))
|
||||
.map_err(|_| format!("No flow {id}"))?;
|
||||
sql.push_str(" AND (started_at, id) < (?, ?)");
|
||||
params.push(started_at);
|
||||
params.push(id.clone().into());
|
||||
}
|
||||
sql.push_str(" ORDER BY started_at DESC, id DESC");
|
||||
if let Some(limit) = filter.limit {
|
||||
sql.push_str(" LIMIT ?");
|
||||
params.push((limit.min(i64::MAX as usize) as i64).into());
|
||||
}
|
||||
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(params), Flow::from_row)
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn count_matching(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result<usize, String> {
|
||||
let (condition, params) = filter.condition();
|
||||
conn.query_row(
|
||||
&format!("SELECT COUNT(*) FROM flows WHERE {condition}"),
|
||||
rusqlite::params_from_iter(params),
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.map(|n| n as usize)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Deletes every flow and their bodies, in a transaction in each database. The flows' deletion
|
||||
/// commits only after their bodies' has, so when clearing the bodies fails the flows stay, to be
|
||||
/// cleared again. Returns how many there were.
|
||||
pub fn clear(db: &ProxyQueryManager, blobs: &BlobManager) -> Result<usize, String> {
|
||||
db.with_tx(|c| {
|
||||
let conn = c.conn().resolve();
|
||||
let mut stmt = conn.prepare("SELECT id FROM flows")?;
|
||||
let ids = stmt.query_map([], |r| r.get(0))?.collect::<Result<Vec<String>, _>>()?;
|
||||
conn.execute("DELETE FROM flows", [])?;
|
||||
// Only the deleted flows' bodies, so a flow still in flight keeps what it has written.
|
||||
blobs
|
||||
.with_tx(|b| {
|
||||
for id in &ids {
|
||||
b.delete_chunks(&Flow::body_id(id, FlowBody::Request))?;
|
||||
b.delete_chunks(&Flow::body_id(id, FlowBody::Response))?;
|
||||
}
|
||||
Ok::<_, yaak_models::error::Error>(())
|
||||
})
|
||||
.map_err(|e| yaak_database::Error::Database(format!("flow bodies: {e}")))?;
|
||||
Ok::<_, yaak_database::Error>(ids.len())
|
||||
})
|
||||
.map_err(|e| format!("Failed to clear flows: {e}"))
|
||||
}
|
||||
|
||||
pub fn find(db: &ProxyQueryManager, flow_id: &str) -> Option<Flow> {
|
||||
db.with_conn(|c| c.find_optional::<Flow>(FlowIden::Id, flow_id))
|
||||
}
|
||||
|
||||
pub fn get(db: &ProxyQueryManager, flow_id: &str) -> Result<Flow, String> {
|
||||
find(db, flow_id).ok_or_else(|| format!("No flow {flow_id}"))
|
||||
}
|
||||
|
||||
pub fn count(db: &ProxyQueryManager) -> Result<usize, String> {
|
||||
db.with_conn(|c| count_in(c.conn().resolve()))
|
||||
}
|
||||
|
||||
fn count_in(conn: &rusqlite::Connection) -> Result<usize, String> {
|
||||
conn.query_row("SELECT COUNT(*) FROM flows", [], |r| r.get::<_, i64>(0))
|
||||
.map(|n| n as usize)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// A body in JSON: text when it is UTF-8, base64 when it is not.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BodyPreview {
|
||||
pub data: String,
|
||||
pub encoding: BodyEncoding,
|
||||
/// Bytes in `data` once decoded.
|
||||
pub size: usize,
|
||||
/// The body is longer than `data`: cut at the capture limit or at the preview cap.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BodyEncoding {
|
||||
Utf8,
|
||||
Base64,
|
||||
}
|
||||
|
||||
impl BodyPreview {
|
||||
pub fn new(data: &[u8], truncated: bool) -> Self {
|
||||
let (data, encoding) = match std::str::from_utf8(data) {
|
||||
Ok(text) => (text.to_string(), BodyEncoding::Utf8),
|
||||
// A cut can split a character; that alone does not make the body binary.
|
||||
Err(e) if truncated && e.error_len().is_none() => {
|
||||
(String::from_utf8_lossy(&data[..e.valid_up_to()]).into_owned(), BodyEncoding::Utf8)
|
||||
}
|
||||
Err(_) => (BASE64.encode(data), BodyEncoding::Base64),
|
||||
};
|
||||
let size = match encoding {
|
||||
BodyEncoding::Utf8 => data.len(),
|
||||
BodyEncoding::Base64 => BASE64.decode(&data).map(|d| d.len()).unwrap_or_default(),
|
||||
};
|
||||
Self { data, encoding, size, truncated }
|
||||
}
|
||||
}
|
||||
|
||||
/// One flow with its bodies, each decoded and cut at `body_cap` bytes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FlowDetail {
|
||||
pub flow: Flow,
|
||||
pub request_body: Option<BodyPreview>,
|
||||
pub response_body: Option<BodyPreview>,
|
||||
}
|
||||
|
||||
pub async fn detail(
|
||||
db: &ProxyQueryManager,
|
||||
blobs: &BlobManager,
|
||||
flow_id: &str,
|
||||
body_cap: usize,
|
||||
) -> Result<FlowDetail, String> {
|
||||
let flow = get(db, flow_id)?;
|
||||
let request_body = preview(blobs, &flow, FlowBody::Request, body_cap).await?;
|
||||
let response_body = preview(blobs, &flow, FlowBody::Response, body_cap).await?;
|
||||
Ok(FlowDetail { flow, request_body, response_body })
|
||||
}
|
||||
|
||||
async fn preview(
|
||||
blobs: &BlobManager,
|
||||
flow: &Flow,
|
||||
which: FlowBody,
|
||||
cap: usize,
|
||||
) -> Result<Option<BodyPreview>, String> {
|
||||
let body = bodies::read_body(blobs, flow, which, cap).await?;
|
||||
if body.data.is_empty() && !body.truncated {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(BodyPreview::new(&body.data, body.truncated)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn flow(url: &str, method: &str, status: Option<i32>, error: Option<&str>) -> Flow {
|
||||
Flow {
|
||||
url: url.into(),
|
||||
method: method.into(),
|
||||
status,
|
||||
error: error.map(String::from),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A database of `flows`, started a second apart in the order given, and a function that
|
||||
/// lists the urls a filter covers, newest first.
|
||||
fn db_of(flows: Vec<Flow>) -> (tempfile::TempDir, ProxyQueryManager) {
|
||||
use yaak_database::UpdateSource;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap();
|
||||
for (i, flow) in flows.into_iter().enumerate() {
|
||||
let started_at = chrono::DateTime::from_timestamp(i as i64, 0).unwrap().naive_utc();
|
||||
let flow = Flow { id: format!("fl_{i:03}"), started_at, ..flow };
|
||||
db.with_tx(|tx| tx.upsert(&flow, &UpdateSource::Background)).unwrap();
|
||||
}
|
||||
(dir, db)
|
||||
}
|
||||
|
||||
fn urls(db: &ProxyQueryManager, filter: FlowFilter) -> Vec<String> {
|
||||
list(db, &filter).unwrap().into_iter().map(|f| f.url).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters() {
|
||||
let (_dir, db) = db_of(vec![
|
||||
flow("https://api.stripe.com:8443/v1/charges?x=1", "POST", Some(200), None),
|
||||
flow("http://example.com/missing", "GET", Some(404), None),
|
||||
flow("https://down.example.com/", "GET", None, Some("refused")),
|
||||
]);
|
||||
let by = |filter: FlowFilter| urls(&db, filter).len();
|
||||
assert_eq!(by(FlowFilter::default()), 3);
|
||||
assert_eq!(by(FlowFilter { host: Some("STRIPE".into()), ..Default::default() }), 1);
|
||||
assert_eq!(by(FlowFilter { host: Some("example.com".into()), ..Default::default() }), 2);
|
||||
assert_eq!(by(FlowFilter { host: Some("charges".into()), ..Default::default() }), 0);
|
||||
assert_eq!(by(FlowFilter { host: Some("8443".into()), ..Default::default() }), 1);
|
||||
assert_eq!(by(FlowFilter { method: Some("get".into()), ..Default::default() }), 2);
|
||||
assert_eq!(by(FlowFilter { status: Some("404".into()), ..Default::default() }), 1);
|
||||
assert_eq!(by(FlowFilter { status: Some("2xx".into()), ..Default::default() }), 1);
|
||||
assert_eq!(by(FlowFilter { status: Some("5XX".into()), ..Default::default() }), 0);
|
||||
assert_eq!(by(FlowFilter { status: Some("40".into()), ..Default::default() }), 0);
|
||||
assert_eq!(by(FlowFilter { status: Some("error".into()), ..Default::default() }), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_url_method_status_and_content_type() {
|
||||
let json = |url: &str, method: &str, status| Flow {
|
||||
content_type: Some("application/json; charset=utf-8".into()),
|
||||
..flow(url, method, Some(status), None)
|
||||
};
|
||||
let (_dir, db) = db_of(vec![
|
||||
json("https://api.stripe.com/v1/charges?customer=cus_9", "POST", 200),
|
||||
json("https://api.github.com/repos/yaak", "GET", 404),
|
||||
Flow {
|
||||
content_type: Some("image/png".into()),
|
||||
..flow("https://images.ctfassets.net/logo.png", "GET", Some(200), None)
|
||||
},
|
||||
flow("http://localhost:3000/health", "HEAD", Some(503), None),
|
||||
]);
|
||||
let search = |q: &str| {
|
||||
let filter = FlowFilter { q: Some(q.into()), ..Default::default() };
|
||||
let mut found = urls(&db, filter);
|
||||
found.sort();
|
||||
found
|
||||
};
|
||||
let stripe = "https://api.stripe.com/v1/charges?customer=cus_9";
|
||||
let github = "https://api.github.com/repos/yaak";
|
||||
let logo = "https://images.ctfassets.net/logo.png";
|
||||
let health = "http://localhost:3000/health";
|
||||
// The host, the path and the query, any case.
|
||||
assert_eq!(search("STRIPE"), vec![stripe]);
|
||||
assert_eq!(search("/repos/"), vec![github]);
|
||||
assert_eq!(search("cus_9"), vec![stripe]);
|
||||
// The method, whole.
|
||||
assert_eq!(search("head"), vec![health]);
|
||||
assert_eq!(search("ead"), Vec::<&str>::new());
|
||||
// A status, its start, or its class.
|
||||
assert_eq!(search("404"), vec![github]);
|
||||
assert_eq!(search("50"), vec![health]);
|
||||
assert_eq!(search("2xx"), vec![stripe, logo]);
|
||||
// The content type.
|
||||
assert_eq!(search("png"), vec![logo]);
|
||||
assert_eq!(search("json"), vec![github, stripe]);
|
||||
// Every term must match, each anywhere.
|
||||
assert_eq!(search("json get"), vec![github]);
|
||||
assert_eq!(search(" json 2xx "), vec![stripe]);
|
||||
assert_eq!(search("json 5xx"), Vec::<&str>::new());
|
||||
// `%` and `_` are plain characters, not patterns.
|
||||
assert_eq!(search("%"), Vec::<&str>::new());
|
||||
assert_eq!(search("cus_"), vec![stripe]);
|
||||
assert_eq!(search(""), vec![health, github, stripe, logo]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn under_matches_a_host_or_a_path_as_the_sidebar_does() {
|
||||
let (_dir, db) = db_of(vec![
|
||||
flow("https://api.stripe.com/v1/charges", "GET", Some(200), None),
|
||||
flow("https://api.stripe.com/v1/charges/ch_1?expand=x", "GET", Some(200), None),
|
||||
flow("https://api.stripe.com/v1/chargesx", "GET", Some(200), None),
|
||||
flow("https://api.stripe.com:8443/v1/charges", "GET", Some(200), None),
|
||||
flow("http://api.stripe.com", "GET", Some(200), None),
|
||||
flow("https://api.stripe.community/", "GET", Some(200), None),
|
||||
flow("https://example.com/v1//users", "GET", Some(200), None),
|
||||
flow("http://x.com:80/a", "GET", Some(200), None),
|
||||
flow("https://x.com:443/a", "GET", Some(200), None),
|
||||
]);
|
||||
let under = |nodes: &[&str]| {
|
||||
let nodes = nodes.iter().map(|n| n.to_string()).collect();
|
||||
urls(&db, FlowFilter { under: Some(nodes), ..Default::default() }).len()
|
||||
};
|
||||
// A host on another port is a node of its own, as in the sidebar.
|
||||
assert_eq!(under(&["api.stripe.com"]), 4);
|
||||
assert_eq!(under(&["api.stripe.com:8443"]), 1);
|
||||
assert_eq!(under(&["api.stripe.com/v1/charges"]), 2);
|
||||
// Empty segments and default ports are left out, as the sidebar leaves them.
|
||||
assert_eq!(under(&["example.com/v1/users"]), 1);
|
||||
assert_eq!(under(&["x.com/a"]), 2);
|
||||
assert_eq!(under(&["api.stripe.com/v1/charges", "example.com"]), 3);
|
||||
assert_eq!(under(&[]), 0);
|
||||
// The sidebar lowercases hosts; paths keep their case.
|
||||
assert_eq!(under(&["API.stripe.com/v1/charges"]), 2);
|
||||
assert_eq!(under(&["api.stripe.com/V1/charges"]), 0);
|
||||
}
|
||||
|
||||
/// The cases `lib/search.test.ts` checks `sidebarPath` against, so the two agree.
|
||||
#[test]
|
||||
fn sidebar_path_rules() {
|
||||
for (url, path) in [
|
||||
("https://example.com/v1//users", "example.com/v1/users"),
|
||||
("http://x.com:80/a", "x.com/a"),
|
||||
("https://x.com:443/a?q=1#f", "x.com/a"),
|
||||
("http://x.com:443/a", "x.com:443/a"),
|
||||
("https://API.Stripe.com:8443/", "api.stripe.com:8443"),
|
||||
("https://api.stripe.com", "api.stripe.com"),
|
||||
("https://api.stripe.com?x=/y", "api.stripe.com"),
|
||||
("http://[::1]:80/a/B", "[::1]/a/B"),
|
||||
("/api", "/api"),
|
||||
] {
|
||||
assert_eq!(sidebar_path(url), path, "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_matches_a_bundle_id_or_a_path() {
|
||||
let from = |bundle_id: Option<&str>, path: &str| Flow {
|
||||
client_bundle_id: bundle_id.map(String::from),
|
||||
client_path: Some(path.into()),
|
||||
..flow("https://example.com/", "GET", Some(200), None)
|
||||
};
|
||||
let (_dir, db) = db_of(vec![
|
||||
from(Some("com.google.Chrome"), "/Applications/Google Chrome.app"),
|
||||
from(Some("com.google.Chrome"), "/Applications/Google Chrome.app"),
|
||||
from(None, "/usr/bin/curl"),
|
||||
flow("https://example.com/", "GET", Some(200), None),
|
||||
]);
|
||||
let app = |apps: &[&str]| {
|
||||
let apps = apps.iter().map(|a| a.to_string()).collect();
|
||||
urls(&db, FlowFilter { app: Some(apps), ..Default::default() }).len()
|
||||
};
|
||||
assert_eq!(app(&["com.google.Chrome"]), 2);
|
||||
assert_eq!(app(&["/usr/bin/curl"]), 1);
|
||||
assert_eq!(app(&["/Applications/Google Chrome.app"]), 0);
|
||||
assert_eq!(app(&["com.google.Chrome", "/usr/bin/curl"]), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_search_pages_back_and_counts_what_it_matches() {
|
||||
// Every third flow is a POST, newest last.
|
||||
let (_dir, db) = db_of(
|
||||
(0..30)
|
||||
.map(|i| {
|
||||
let method = if i % 3 == 0 { "POST" } else { "GET" };
|
||||
flow(&format!("https://api.example.com/{i}"), method, Some(200), None)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let search = |before: Option<String>| FlowFilter {
|
||||
q: Some("post".into()),
|
||||
before,
|
||||
limit: Some(4),
|
||||
..Default::default()
|
||||
};
|
||||
let (first, total, matching) = page(&db, &search(None)).unwrap();
|
||||
let ids = |flows: &[Flow]| flows.iter().map(|f| f.id.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&first), vec!["fl_027", "fl_024", "fl_021", "fl_018"]);
|
||||
assert_eq!((total, matching), (30, Some(10)));
|
||||
let (second, _, _) = page(&db, &search(Some("fl_018".into()))).unwrap();
|
||||
assert_eq!(ids(&second), vec!["fl_015", "fl_012", "fl_009", "fl_006"]);
|
||||
let (last, _, _) = page(&db, &search(Some("fl_006".into()))).unwrap();
|
||||
assert_eq!(ids(&last), vec!["fl_003", "fl_000"]);
|
||||
// Without a narrowing filter there is nothing more to count.
|
||||
let (_, _, matching) =
|
||||
page(&db, &FlowFilter { limit: Some(1), ..Default::default() }).unwrap();
|
||||
assert_eq!(matching, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_search_reads_the_newest_page_through_the_index() {
|
||||
let (_dir, db) = db_of(vec![]);
|
||||
let plan: Vec<String> = db.with_conn(|c| {
|
||||
let conn = c.conn().resolve();
|
||||
let (condition, params) =
|
||||
FlowFilter { q: Some("stripe".into()), ..Default::default() }.condition();
|
||||
let sql = format!(
|
||||
"EXPLAIN QUERY PLAN SELECT * FROM flows WHERE {condition} \
|
||||
ORDER BY started_at DESC, id DESC LIMIT 500"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql).unwrap();
|
||||
stmt.query_map(rusqlite::params_from_iter(params), |r| r.get::<_, String>(3))
|
||||
.unwrap()
|
||||
.map(|r| r.unwrap())
|
||||
.collect()
|
||||
});
|
||||
assert!(plan.iter().any(|p| p.contains("idx_flows_started_at_id")), "{plan:?}");
|
||||
assert!(!plan.iter().any(|p| p.contains("TEMP B-TREE")), "{plan:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_follows_flows_as_they_are_recorded() {
|
||||
use yaak_database::UpdateSource;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap();
|
||||
let at = |s| chrono::DateTime::from_timestamp(s, 0).unwrap().naive_utc();
|
||||
// A slow flow that started first but ended, and so was written, after a quick one.
|
||||
for (id, started) in [("fl_quick", 20), ("fl_slow", 10), ("fl_next", 30)] {
|
||||
let flow = Flow { id: id.into(), started_at: at(started), ..Default::default() };
|
||||
db.with_tx(|tx| tx.upsert(&flow, &UpdateSource::Background)).unwrap();
|
||||
}
|
||||
|
||||
let after = |id: &str| {
|
||||
let filter = FlowFilter { after: Some(id.into()), ..Default::default() };
|
||||
list(&db, &filter).unwrap().into_iter().map(|f| f.id).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(after("fl_quick"), vec!["fl_next", "fl_slow"]);
|
||||
assert_eq!(after("fl_next"), Vec::<String>::new());
|
||||
let missing = FlowFilter { after: Some("fl_gone".into()), ..Default::default() };
|
||||
assert_eq!(list(&db, &missing).unwrap_err(), "No flow fl_gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn before_pages_back_through_older_flows() {
|
||||
use yaak_database::UpdateSource;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap();
|
||||
let at = |s| chrono::DateTime::from_timestamp(s, 0).unwrap().naive_utc();
|
||||
// Two started together, which their ids order.
|
||||
for (id, started) in [
|
||||
("fl_a", 10),
|
||||
("fl_b", 20),
|
||||
("fl_c", 20),
|
||||
("fl_d", 30),
|
||||
("fl_e", 40),
|
||||
] {
|
||||
let flow = Flow {
|
||||
id: id.into(),
|
||||
started_at: at(started),
|
||||
status: Some(if id == "fl_c" { 404 } else { 200 }),
|
||||
..Default::default()
|
||||
};
|
||||
db.with_tx(|tx| tx.upsert(&flow, &UpdateSource::Background)).unwrap();
|
||||
}
|
||||
|
||||
let page = |filter: FlowFilter| {
|
||||
list(&db, &filter).unwrap().into_iter().map(|f| f.id).collect::<Vec<_>>()
|
||||
};
|
||||
let newest = FlowFilter { limit: Some(2), ..Default::default() };
|
||||
assert_eq!(page(newest), vec!["fl_e", "fl_d"]);
|
||||
let older =
|
||||
FlowFilter { before: Some("fl_d".into()), limit: Some(2), ..Default::default() };
|
||||
assert_eq!(page(older), vec!["fl_c", "fl_b"]);
|
||||
let oldest =
|
||||
FlowFilter { before: Some("fl_b".into()), limit: Some(2), ..Default::default() };
|
||||
assert_eq!(page(oldest), vec!["fl_a"]);
|
||||
// The limit counts flows the filter keeps.
|
||||
let ok = FlowFilter {
|
||||
before: Some("fl_e".into()),
|
||||
status: Some("2xx".into()),
|
||||
limit: Some(2),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(page(ok), vec!["fl_d", "fl_b"]);
|
||||
let missing = FlowFilter { before: Some("fl_gone".into()), ..Default::default() };
|
||||
assert_eq!(list(&db, &missing).unwrap_err(), "No flow fl_gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_deletes_flows_and_their_bodies() {
|
||||
use yaak_database::UpdateSource;
|
||||
use yaak_models::blob_manager::BodyChunkWriter;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap();
|
||||
let blobs = yaak_models::init_blob_store(dir.path().join("blobs.sqlite")).unwrap();
|
||||
let write_body = |id: &str, which| {
|
||||
let mut writer = BodyChunkWriter::new(blobs.clone(), Flow::body_id(id, which));
|
||||
writer.write(b"body").unwrap();
|
||||
writer.finish().unwrap();
|
||||
};
|
||||
for id in ["fl_a", "fl_b"] {
|
||||
write_body(id, FlowBody::Request);
|
||||
write_body(id, FlowBody::Response);
|
||||
let flow = Flow { id: id.into(), ..Default::default() };
|
||||
db.with_tx(|tx| tx.upsert(&flow, &UpdateSource::Background)).unwrap();
|
||||
}
|
||||
// A flow still in flight has written a body but not its row.
|
||||
write_body("fl_live", FlowBody::Request);
|
||||
|
||||
// Bodies that fail to clear keep their flows, to clear again.
|
||||
let read_only = crate::db::open_blobs_read_only(&dir.path().join("blobs.sqlite")).unwrap();
|
||||
assert!(clear(&db, &read_only).is_err());
|
||||
assert_eq!(count(&db).unwrap(), 2);
|
||||
|
||||
assert_eq!(clear(&db, &blobs).unwrap(), 2);
|
||||
assert_eq!(count(&db).unwrap(), 0);
|
||||
let body_ids = blobs.connect().list_body_ids().unwrap();
|
||||
assert_eq!(body_ids, vec![Flow::body_id("fl_live", FlowBody::Request)]);
|
||||
assert_eq!(clear(&db, &blobs).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_round_trip() {
|
||||
let filter = FlowFilter {
|
||||
q: Some("post json".into()),
|
||||
host: Some("api.stripe.com".into()),
|
||||
under: Some(vec!["api.stripe.com/v1".into(), "example.com".into()]),
|
||||
app: Some(vec!["com.google.Chrome".into()]),
|
||||
status: Some("4xx".into()),
|
||||
limit: Some(20),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
filter.to_query(),
|
||||
"q=post+json&host=api.stripe.com&under=api.stripe.com%2Fv1&under=example.com\
|
||||
&app=com.google.Chrome&status=4xx&limit=20"
|
||||
);
|
||||
assert_eq!(FlowFilter::from_query(&filter.to_query()).unwrap(), filter);
|
||||
assert_eq!(FlowFilter::from_query("").unwrap(), FlowFilter::default());
|
||||
assert!(FlowFilter::from_query("limit=lots").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews() {
|
||||
let text = BodyPreview::new("héllo".as_bytes(), false);
|
||||
assert_eq!(
|
||||
(text.data.as_str(), text.encoding, text.size),
|
||||
("héllo", BodyEncoding::Utf8, 6)
|
||||
);
|
||||
|
||||
// Cut inside the "é".
|
||||
let cut = BodyPreview::new(&"héllo".as_bytes()[..2], true);
|
||||
assert_eq!(
|
||||
(cut.data.as_str(), cut.encoding, cut.truncated),
|
||||
("h", BodyEncoding::Utf8, true)
|
||||
);
|
||||
|
||||
let binary = BodyPreview::new(&[0xff, 0x00, 0xfe], false);
|
||||
assert_eq!(
|
||||
(binary.data.as_str(), binary.encoding, binary.size),
|
||||
("/wD+", BodyEncoding::Base64, 3)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
//! HAR 1.2 export (http://www.softwareishard.com/blog/har-12-spec/). Bodies are decoded with
|
||||
//! their Content-Encoding; one that is not UTF-8 is written in base64 with `encoding: "base64"`.
|
||||
//! Fields the spec leaves to the exporter start with an underscore: `_truncated` on a body cut
|
||||
//! at the capture limit and `_error` on a flow that failed.
|
||||
|
||||
use crate::bodies::{self, DecodedBody};
|
||||
use crate::flows::{BodyEncoding, BodyPreview};
|
||||
use crate::models::{Flow, FlowBody, ProxyHeader, find_header};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Har {
|
||||
pub log: HarLog,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HarLog {
|
||||
pub version: String,
|
||||
pub creator: HarCreator,
|
||||
pub entries: Vec<HarEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HarCreator {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HarEntry {
|
||||
pub started_date_time: String,
|
||||
pub time: i64,
|
||||
pub request: HarRequest,
|
||||
pub response: HarResponse,
|
||||
pub cache: serde_json::Map<String, serde_json::Value>,
|
||||
pub timings: HarTimings,
|
||||
#[serde(rename = "serverIPAddress", skip_serializing_if = "Option::is_none")]
|
||||
pub server_ip_address: Option<String>,
|
||||
#[serde(rename = "_error", skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HarRequest {
|
||||
pub method: String,
|
||||
pub url: String,
|
||||
pub http_version: String,
|
||||
pub cookies: Vec<HarNameValue>,
|
||||
pub headers: Vec<HarNameValue>,
|
||||
pub query_string: Vec<HarNameValue>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub post_data: Option<HarPostData>,
|
||||
pub headers_size: i64,
|
||||
pub body_size: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HarResponse {
|
||||
pub status: i32,
|
||||
pub status_text: String,
|
||||
pub http_version: String,
|
||||
pub cookies: Vec<HarNameValue>,
|
||||
pub headers: Vec<HarNameValue>,
|
||||
pub content: HarContent,
|
||||
#[serde(rename = "redirectURL")]
|
||||
pub redirect_url: String,
|
||||
pub headers_size: i64,
|
||||
pub body_size: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HarNameValue {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HarPostData {
|
||||
pub mime_type: String,
|
||||
pub text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub encoding: Option<String>,
|
||||
#[serde(
|
||||
rename = "_truncated",
|
||||
skip_serializing_if = "std::ops::Not::not",
|
||||
default
|
||||
)]
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HarContent {
|
||||
pub size: i64,
|
||||
pub mime_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub encoding: Option<String>,
|
||||
#[serde(
|
||||
rename = "_truncated",
|
||||
skip_serializing_if = "std::ops::Not::not",
|
||||
default
|
||||
)]
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HarTimings {
|
||||
pub send: i64,
|
||||
pub wait: i64,
|
||||
pub receive: i64,
|
||||
}
|
||||
|
||||
/// A HAR of `flows`, reading each body from `blobs` and keeping at most `limit` bytes of it.
|
||||
pub async fn export(blobs: &BlobManager, flows: &[Flow], limit: usize) -> Result<Har, String> {
|
||||
let mut entries = Vec::with_capacity(flows.len());
|
||||
for flow in flows {
|
||||
let request = bodies::read_body(blobs, flow, FlowBody::Request, limit).await?;
|
||||
let response = bodies::read_body(blobs, flow, FlowBody::Response, limit).await?;
|
||||
entries.push(entry(flow, &request, &response));
|
||||
}
|
||||
Ok(har(entries))
|
||||
}
|
||||
|
||||
pub fn har(entries: Vec<HarEntry>) -> Har {
|
||||
Har {
|
||||
log: HarLog {
|
||||
version: "1.2".into(),
|
||||
creator: HarCreator {
|
||||
name: "Yaak Proxy".into(),
|
||||
version: env!("CARGO_PKG_VERSION").into(),
|
||||
},
|
||||
entries,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn entry(flow: &Flow, request_body: &DecodedBody, response_body: &DecodedBody) -> HarEntry {
|
||||
let elapsed = flow.elapsed_ms.map_or(0, i64::from).max(0);
|
||||
let wait = flow.elapsed_headers_ms.map_or(elapsed, i64::from).clamp(0, elapsed);
|
||||
let responded = flow.status.is_some();
|
||||
|
||||
HarEntry {
|
||||
started_date_time: flow.started_at.and_utc().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
|
||||
time: elapsed,
|
||||
request: HarRequest {
|
||||
method: flow.method.clone(),
|
||||
url: flow.url.clone(),
|
||||
http_version: flow.client_http_version.clone(),
|
||||
cookies: Vec::new(),
|
||||
headers: name_values(&flow.request_headers),
|
||||
query_string: query_string(&flow.url),
|
||||
post_data: post_data(flow, request_body),
|
||||
headers_size: -1,
|
||||
body_size: flow.request_size,
|
||||
},
|
||||
response: HarResponse {
|
||||
status: flow.status.unwrap_or(0),
|
||||
status_text: flow
|
||||
.status
|
||||
.and_then(|s| hyper::StatusCode::from_u16(u16::try_from(s).ok()?).ok())
|
||||
.and_then(|s| s.canonical_reason())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
http_version: flow.upstream_http_version.clone().unwrap_or_default(),
|
||||
cookies: Vec::new(),
|
||||
headers: name_values(&flow.response_headers),
|
||||
content: content(flow, response_body),
|
||||
redirect_url: find_header(&flow.response_headers, "location")
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
headers_size: -1,
|
||||
body_size: if responded { flow.response_size } else { -1 },
|
||||
},
|
||||
cache: Default::default(),
|
||||
timings: HarTimings { send: 0, wait, receive: elapsed - wait },
|
||||
server_ip_address: flow.remote_addr.as_deref().map(ip_of),
|
||||
error: flow
|
||||
.error
|
||||
.clone()
|
||||
.or_else(|| response_body.decode_error.clone())
|
||||
.or_else(|| request_body.decode_error.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn name_values(headers: &[ProxyHeader]) -> Vec<HarNameValue> {
|
||||
headers.iter().map(|h| HarNameValue { name: h.name.clone(), value: h.value.clone() }).collect()
|
||||
}
|
||||
|
||||
fn query_string(url: &str) -> Vec<HarNameValue> {
|
||||
let Some((_, query)) = url.split_once('?') else {
|
||||
return Vec::new();
|
||||
};
|
||||
let query = query.split('#').next().unwrap_or_default();
|
||||
url::form_urlencoded::parse(query.as_bytes())
|
||||
.map(|(name, value)| HarNameValue { name: name.into_owned(), value: value.into_owned() })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn post_data(flow: &Flow, body: &DecodedBody) -> Option<HarPostData> {
|
||||
if body.data.is_empty() && !body.truncated {
|
||||
return None;
|
||||
}
|
||||
let preview = BodyPreview::new(&body.data, body.truncated);
|
||||
Some(HarPostData {
|
||||
mime_type: find_header(&flow.request_headers, "content-type").unwrap_or_default().into(),
|
||||
encoding: base64_marker(preview.encoding),
|
||||
text: preview.data,
|
||||
truncated: body.truncated,
|
||||
})
|
||||
}
|
||||
|
||||
fn content(flow: &Flow, body: &DecodedBody) -> HarContent {
|
||||
let mime_type = find_header(&flow.response_headers, "content-type").unwrap_or_default().into();
|
||||
if body.data.is_empty() && !body.truncated {
|
||||
return HarContent { size: 0, mime_type, text: None, encoding: None, truncated: false };
|
||||
}
|
||||
let preview = BodyPreview::new(&body.data, body.truncated);
|
||||
HarContent {
|
||||
size: body.data.len() as i64,
|
||||
mime_type,
|
||||
encoding: base64_marker(preview.encoding),
|
||||
text: Some(preview.data),
|
||||
truncated: body.truncated,
|
||||
}
|
||||
}
|
||||
|
||||
fn base64_marker(encoding: BodyEncoding) -> Option<String> {
|
||||
(encoding == BodyEncoding::Base64).then(|| "base64".to_string())
|
||||
}
|
||||
|
||||
/// The address without its port: `127.0.0.1:443` to `127.0.0.1`, `[::1]:443` to `::1`.
|
||||
fn ip_of(addr: &str) -> String {
|
||||
match addr.parse::<std::net::SocketAddr>() {
|
||||
Ok(a) => a.ip().to_string(),
|
||||
Err(_) => addr.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::NaiveDate;
|
||||
|
||||
const GOLDEN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/export.har");
|
||||
|
||||
fn header(name: &str, value: &str) -> ProxyHeader {
|
||||
ProxyHeader { name: name.into(), value: value.into() }
|
||||
}
|
||||
|
||||
fn body(data: &[u8], truncated: bool) -> DecodedBody {
|
||||
DecodedBody { data: data.to_vec(), stored_size: data.len(), truncated, decode_error: None }
|
||||
}
|
||||
|
||||
fn at(second: u32) -> chrono::NaiveDateTime {
|
||||
NaiveDate::from_ymd_opt(2026, 10, 26)
|
||||
.unwrap()
|
||||
.and_hms_milli_opt(9, 30, second, 250)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn flows() -> Vec<(Flow, DecodedBody, DecodedBody)> {
|
||||
let json = Flow {
|
||||
id: "fl_json".into(),
|
||||
started_at: at(1),
|
||||
method: "GET".into(),
|
||||
url:
|
||||
"https://api.github.com/repos/mountain-loop/yaak/issues?state=open&labels=bug%20fix"
|
||||
.into(),
|
||||
client_http_version: "HTTP/1.1".into(),
|
||||
upstream_http_version: Some("HTTP/2".into()),
|
||||
remote_addr: Some("140.82.112.6:443".into()),
|
||||
status: Some(200),
|
||||
content_type: Some("application/json; charset=utf-8".into()),
|
||||
request_headers: vec![
|
||||
header("Host", "api.github.com"),
|
||||
header("Accept", "application/vnd.github+json"),
|
||||
],
|
||||
response_headers: vec![
|
||||
header("content-type", "application/json; charset=utf-8"),
|
||||
header("content-encoding", "gzip"),
|
||||
],
|
||||
response_size: 41,
|
||||
elapsed_headers_ms: Some(120),
|
||||
elapsed_ms: Some(134),
|
||||
..Default::default()
|
||||
};
|
||||
let form = Flow {
|
||||
id: "fl_form".into(),
|
||||
started_at: at(2),
|
||||
method: "POST".into(),
|
||||
url: "http://localhost:3000/login".into(),
|
||||
client_http_version: "HTTP/1.1".into(),
|
||||
upstream_http_version: Some("HTTP/1.1".into()),
|
||||
remote_addr: Some("[::1]:3000".into()),
|
||||
status: Some(302),
|
||||
request_headers: vec![header("Content-Type", "application/x-www-form-urlencoded")],
|
||||
response_headers: vec![header("Location", "/dashboard")],
|
||||
request_size: 26,
|
||||
elapsed_headers_ms: Some(8),
|
||||
elapsed_ms: Some(8),
|
||||
..Default::default()
|
||||
};
|
||||
let image = Flow {
|
||||
id: "fl_image".into(),
|
||||
started_at: at(3),
|
||||
method: "GET".into(),
|
||||
url: "https://images.example.com/logo.png".into(),
|
||||
client_http_version: "HTTP/1.1".into(),
|
||||
upstream_http_version: Some("HTTP/2".into()),
|
||||
status: Some(200),
|
||||
response_headers: vec![header("Content-Type", "image/png")],
|
||||
response_size: 8,
|
||||
elapsed_headers_ms: Some(30),
|
||||
elapsed_ms: Some(31),
|
||||
..Default::default()
|
||||
};
|
||||
let cut = Flow {
|
||||
id: "fl_cut".into(),
|
||||
started_at: at(4),
|
||||
method: "GET".into(),
|
||||
url: "https://cdn.example.com/big.txt".into(),
|
||||
client_http_version: "HTTP/1.1".into(),
|
||||
upstream_http_version: Some("HTTP/1.1".into()),
|
||||
status: Some(200),
|
||||
response_headers: vec![header("Content-Type", "text/plain")],
|
||||
response_size: 20_000_000,
|
||||
response_truncated: true,
|
||||
elapsed_headers_ms: Some(10),
|
||||
elapsed_ms: Some(900),
|
||||
error: Some("connection reset by peer".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let failed = Flow {
|
||||
id: "fl_failed".into(),
|
||||
started_at: at(5),
|
||||
method: "GET".into(),
|
||||
url: "https://down.example.com/".into(),
|
||||
client_http_version: "HTTP/1.1".into(),
|
||||
elapsed_ms: Some(3),
|
||||
error: Some("Connection refused (os error 61)".into()),
|
||||
..Default::default()
|
||||
};
|
||||
vec![
|
||||
(json, body(b"", false), body(br#"[{"number":761,"title":"MCP"}]"#, false)),
|
||||
(form, body(b"user=greg&password=hunter2", false), body(b"", false)),
|
||||
(
|
||||
image,
|
||||
body(b"", false),
|
||||
body(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a], false),
|
||||
),
|
||||
(cut, body(b"", false), body(b"0123456789", true)),
|
||||
(failed, body(b"", false), body(b"", false)),
|
||||
]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn export_keeps_a_body_that_fails_to_decode() {
|
||||
use std::io::Write;
|
||||
use yaak_models::blob_manager::BodyChunkWriter;
|
||||
|
||||
// Text that does not compress to nothing, gzipped, with the stream cut in half.
|
||||
let mut seed = 7u32;
|
||||
let text: Vec<u8> = (0..8000)
|
||||
.map(|_| {
|
||||
seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345);
|
||||
b'a' + (seed >> 16) as u8 % 26
|
||||
})
|
||||
.collect();
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
gz.write_all(&text).unwrap();
|
||||
let gz = gz.finish().unwrap();
|
||||
|
||||
let (_db, blobs, _rx) = yaak_models::init_in_memory().unwrap();
|
||||
let mut writer =
|
||||
BodyChunkWriter::new(blobs.clone(), Flow::body_id("fl_bad", FlowBody::Response));
|
||||
writer.write(&gz[..gz.len() / 2]).unwrap();
|
||||
writer.finish().unwrap();
|
||||
let flow = Flow {
|
||||
id: "fl_bad".into(),
|
||||
method: "GET".into(),
|
||||
url: "https://cdn.example.com/a.txt".into(),
|
||||
status: Some(200),
|
||||
response_headers: vec![
|
||||
header("Content-Type", "text/plain"),
|
||||
header("Content-Encoding", "gzip"),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let har = export(&blobs, &[flow], 1024 * 1024).await.unwrap();
|
||||
let entry = &har.log.entries[0];
|
||||
let text_kept = entry.response.content.text.as_deref().unwrap();
|
||||
assert!(!text_kept.is_empty() && text.starts_with(text_kept.as_bytes()));
|
||||
assert!(entry.response.content.truncated);
|
||||
assert!(
|
||||
entry.error.as_deref().unwrap().starts_with("Failed to decode"),
|
||||
"{:?}",
|
||||
entry.error
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_file() {
|
||||
let entries = flows().iter().map(|(f, req, res)| entry(f, req, res)).collect();
|
||||
let mut har = har(entries);
|
||||
har.log.creator.version = "test".into();
|
||||
let actual = serde_json::to_string_pretty(&har).unwrap() + "\n";
|
||||
|
||||
if std::env::var_os("UPDATE_GOLDEN").is_some() {
|
||||
std::fs::write(GOLDEN, &actual).unwrap();
|
||||
}
|
||||
let expected = std::fs::read_to_string(GOLDEN).unwrap();
|
||||
assert_eq!(actual, expected, "rerun with UPDATE_GOLDEN=1 to accept a change");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! The privileged helper from the app's side: its registration with launchd through
|
||||
//! `SMAppService`, and requests over its socket. Only the bundled, signed app can register it
|
||||
//! or be answered by it; `tauri dev` and the CLI see it as unavailable.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub enum HelperState {
|
||||
/// Not running from a bundle with the helper inside: a dev build, the CLI, or not macOS.
|
||||
Unavailable,
|
||||
NotRegistered,
|
||||
/// Registered, and waiting for the user to allow it in System Settings.
|
||||
RequiresApproval,
|
||||
Enabled,
|
||||
/// The app has the helper's plist, but `SMAppService` reports it not found.
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use super::HelperState;
|
||||
use objc2::rc::Retained;
|
||||
use objc2_foundation::NSString;
|
||||
use objc2_service_management::{SMAppService, SMAppServiceStatus};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
use yaak_proxy_helper::protocol::{Request, Response, Status};
|
||||
|
||||
/// How long a just-approved daemon gets to open its socket.
|
||||
const CONNECT_WAIT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Long enough for `networksetup` on every service.
|
||||
const IO_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// The helper's plist inside this app, when it runs from a bundle that has one, so
|
||||
/// `SMAppService` can find it.
|
||||
fn plist_path() -> Option<PathBuf> {
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let contents = exe.parent()?.parent()?;
|
||||
let path = contents.join("Library/LaunchDaemons").join(yaak_proxy_helper::PLIST_NAME);
|
||||
path.is_file().then_some(path)
|
||||
}
|
||||
|
||||
fn service() -> Option<Retained<SMAppService>> {
|
||||
plist_path()?;
|
||||
let plist = NSString::from_str(yaak_proxy_helper::PLIST_NAME);
|
||||
// SAFETY: a class method taking a valid NSString.
|
||||
Some(unsafe { SMAppService::daemonServiceWithPlistName(&plist) })
|
||||
}
|
||||
|
||||
fn raw_status(service: &SMAppService) -> SMAppServiceStatus {
|
||||
// SAFETY: reads the service's registration status.
|
||||
unsafe { service.status() }
|
||||
}
|
||||
|
||||
fn state_of(service: &SMAppService) -> HelperState {
|
||||
match raw_status(service) {
|
||||
SMAppServiceStatus::Enabled => HelperState::Enabled,
|
||||
SMAppServiceStatus::RequiresApproval => HelperState::RequiresApproval,
|
||||
SMAppServiceStatus::NotRegistered => HelperState::NotRegistered,
|
||||
_ => HelperState::NotFound,
|
||||
}
|
||||
}
|
||||
|
||||
fn status_name(status: SMAppServiceStatus) -> &'static str {
|
||||
match status {
|
||||
SMAppServiceStatus::NotRegistered => "notRegistered",
|
||||
SMAppServiceStatus::Enabled => "enabled",
|
||||
SMAppServiceStatus::RequiresApproval => "requiresApproval",
|
||||
SMAppServiceStatus::NotFound => "notFound",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state() -> HelperState {
|
||||
let Some(plist) = plist_path() else {
|
||||
log::debug!("No helper plist in this app, so the helper is unavailable");
|
||||
return HelperState::Unavailable;
|
||||
};
|
||||
let Some(service) = service() else {
|
||||
return HelperState::Unavailable;
|
||||
};
|
||||
let status = raw_status(&service);
|
||||
log::debug!(
|
||||
"SMAppService status {} ({}) for {}",
|
||||
status.0,
|
||||
status_name(status),
|
||||
plist.display()
|
||||
);
|
||||
state_of(&service)
|
||||
}
|
||||
|
||||
/// What `SMAppService` says about the helper, for `--helper-status`.
|
||||
pub fn diagnose() -> String {
|
||||
let exe = std::env::current_exe()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|e| format!("unknown ({e})"));
|
||||
let mut lines = vec![format!("executable: {exe}")];
|
||||
match plist_path() {
|
||||
Some(plist) => lines.push(format!("plist: {}", plist.display())),
|
||||
None => lines.push(format!(
|
||||
"plist: none at Contents/Library/LaunchDaemons/{}",
|
||||
yaak_proxy_helper::PLIST_NAME
|
||||
)),
|
||||
}
|
||||
if let Some(service) = service() {
|
||||
let status = raw_status(&service);
|
||||
lines.push(format!("SMAppService status: {} ({})", status.0, status_name(status)));
|
||||
}
|
||||
lines.push(format!("state: {:?}", state()));
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Registers the daemon and, while it awaits approval, opens the System Settings pane
|
||||
/// that allows it.
|
||||
pub fn register() -> Result<HelperState, String> {
|
||||
let service = service().ok_or_else(unavailable)?;
|
||||
// SAFETY: registers the plist bundled with this app.
|
||||
let registered = unsafe { service.registerAndReturnError() };
|
||||
let state = state_of(&service);
|
||||
match state {
|
||||
// Registering asks for approval by failing with "Operation not permitted".
|
||||
HelperState::RequiresApproval => {
|
||||
// SAFETY: opens System Settings; no arguments.
|
||||
unsafe { SMAppService::openSystemSettingsLoginItems() };
|
||||
Ok(state)
|
||||
}
|
||||
HelperState::Enabled => Ok(state),
|
||||
_ => Err(registered
|
||||
.err()
|
||||
.map(|e| format!("Failed to install the helper: {}", e.localizedDescription()))
|
||||
.unwrap_or_else(|| "Failed to install the helper".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the daemon, once it has put the system proxy back.
|
||||
pub fn unregister() -> Result<HelperState, String> {
|
||||
let service = service().ok_or_else(unavailable)?;
|
||||
// A helper that has not put the settings back stays registered, so launchd restarts it
|
||||
// and it can try again; removed, nothing could restore them.
|
||||
if state_of(&service) == HelperState::Enabled {
|
||||
request(&Request::Unregister).map_err(|e| {
|
||||
format!(
|
||||
"The helper could not restore your proxy settings, so it was kept: {e}. Try \
|
||||
again in a moment."
|
||||
)
|
||||
})?;
|
||||
}
|
||||
// SAFETY: unregisters the plist bundled with this app.
|
||||
unsafe { service.unregisterAndReturnError() }
|
||||
.map_err(|e| format!("Failed to remove the helper: {}", e.localizedDescription()))?;
|
||||
Ok(state_of(&service))
|
||||
}
|
||||
|
||||
fn unavailable() -> String {
|
||||
"The helper is only available in the installed app, not a dev build".to_string()
|
||||
}
|
||||
|
||||
/// Sends one request and reads its response.
|
||||
pub fn request(request: &Request) -> Result<Status, String> {
|
||||
let start = Instant::now();
|
||||
let stream = loop {
|
||||
match UnixStream::connect(yaak_proxy_helper::SOCKET_PATH) {
|
||||
Ok(stream) => break stream,
|
||||
Err(_) if start.elapsed() < CONNECT_WAIT => {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
Err(e) => return Err(format!("The helper is not running: {e}")),
|
||||
}
|
||||
};
|
||||
exchange(stream, request)
|
||||
}
|
||||
|
||||
fn exchange(stream: UnixStream, request: &Request) -> Result<Status, String> {
|
||||
let _ = stream.set_read_timeout(Some(IO_TIMEOUT));
|
||||
let _ = stream.set_write_timeout(Some(IO_TIMEOUT));
|
||||
let mut line = serde_json::to_string(request).map_err(|e| e.to_string())?;
|
||||
line.push('\n');
|
||||
(&stream).write_all(line.as_bytes()).map_err(|e| format!("The helper hung up: {e}"))?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new(&stream)
|
||||
.read_line(&mut reply)
|
||||
.map_err(|e| format!("The helper did not answer: {e}"))?;
|
||||
let response: Response = serde_json::from_str(&reply)
|
||||
.map_err(|e| format!("The helper's answer was not understood: {e}"))?;
|
||||
response.into_result()
|
||||
}
|
||||
|
||||
/// Points the system proxy at `port`, or puts back what was there, owned by this process.
|
||||
pub fn set(enabled: bool, port: u16) -> Result<(), String> {
|
||||
let owner_pid = std::process::id() as i32;
|
||||
request(&Request::Set { enabled, port, owner_pid }).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use imp::{diagnose, register, set, state, unregister};
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn state() -> HelperState {
|
||||
HelperState::Unavailable
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn diagnose() -> String {
|
||||
"The helper is only available on macOS".to_string()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn register() -> Result<HelperState, String> {
|
||||
Err("The helper is only available on macOS".to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn unregister() -> Result<HelperState, String> {
|
||||
Ok(HelperState::Unavailable)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,495 @@
|
||||
//! The macOS system proxy: `networksetup` pointing the web and secure web proxies of every
|
||||
//! enabled network service at `127.0.0.1:<port>`. Reading needs no rights; changing needs an
|
||||
//! administrator, which the privileged helper is, and which the password dialog and `sudo`
|
||||
//! borrow for one script. Each path saves the settings it replaces and puts them back when
|
||||
//! turned off. Everything here blocks, the password dialog included, so async callers go
|
||||
//! through `spawn_blocking`.
|
||||
|
||||
use crate::helper::HelperState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use ts_rs::TS;
|
||||
use yaak_proxy_helper::networksetup::{
|
||||
NETWORKSETUP, Networksetup, Snapshot, disable_commands, enable_commands, read_services,
|
||||
};
|
||||
|
||||
pub use yaak_proxy_helper::networksetup::{ProxySetting, ServiceProxy};
|
||||
|
||||
/// How a change gets administrator rights.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub enum SystemProxyBackend {
|
||||
/// The privileged helper, approved once in System Settings. No prompt.
|
||||
Helper,
|
||||
/// The standard macOS password dialog, through `osascript`.
|
||||
AdminPrompt,
|
||||
/// `sudo` in the terminal running the CLI daemon.
|
||||
Sudo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct SystemProxyStatus {
|
||||
/// Every enabled network service sends HTTP and HTTPS to `127.0.0.1:<port>`.
|
||||
pub enabled: bool,
|
||||
/// The port checked against.
|
||||
pub port: u16,
|
||||
pub services: Vec<ServiceProxy>,
|
||||
pub backend: SystemProxyBackend,
|
||||
/// The privileged helper's registration, which decides the backend.
|
||||
pub helper: HelperState,
|
||||
/// This platform has a system proxy the app can set.
|
||||
pub supported: bool,
|
||||
}
|
||||
|
||||
/// Where the password dialog and `sudo` save the settings they replace, in the proxy data
|
||||
/// directory. The helper keeps its own, as root.
|
||||
pub const SNAPSHOT_FILE: &str = "system-proxy-snapshot.json";
|
||||
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn load_snapshot(path: &Path) -> Result<Option<Snapshot>, String> {
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", path.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(format!("Failed to read {}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The port the password dialog or `sudo` last pointed the system proxy at, while they have
|
||||
/// settings saved to put back.
|
||||
pub fn saved_port(snapshot_path: &Path) -> Option<u16> {
|
||||
// Port 0 holds only settings no service matched, which route nothing.
|
||||
load_snapshot(snapshot_path).ok().flatten().map(|s| s.port).filter(|p| *p != 0)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn save_snapshot(path: &Path, snapshot: &Snapshot) -> Result<(), String> {
|
||||
let json = serde_json::to_vec_pretty(snapshot).map_err(|e| e.to_string())?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, json)
|
||||
.and_then(|()| std::fs::rename(&tmp, path))
|
||||
.map_err(|e| format!("Failed to save {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn remove_snapshot(path: &Path) -> Result<(), String> {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("Failed to delete {}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The password-dialog and `sudo` paths: reads through `ns`, and changes through `run`, which
|
||||
/// gets administrator rights for the whole list at once. Turning on saves the settings it
|
||||
/// replaces at `snapshot_path` first; turning off puts them back and deletes the file.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn set_scripted(
|
||||
ns: &impl Networksetup,
|
||||
run: impl FnOnce(&[Vec<String>]) -> Result<(), String>,
|
||||
enabled: bool,
|
||||
port: u16,
|
||||
snapshot_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
if enabled {
|
||||
let services = read_services(ns, false)?;
|
||||
let saved = load_snapshot(snapshot_path)?;
|
||||
let mut snapshot = saved.clone().unwrap_or_default();
|
||||
snapshot.extend(&services, port);
|
||||
// Saved before anything changes, so a crash after the change still restores.
|
||||
save_snapshot(snapshot_path, &snapshot)?;
|
||||
if let Err(e) = run(&enable_commands(&services, port)) {
|
||||
// Usually cancelled, with nothing changed, and a snapshot kept would go stale, so
|
||||
// what was there before goes back. Once anything points at us, it is what puts the
|
||||
// rest back.
|
||||
let changed = read_services(ns, false)?.iter().any(|s| s.points_at(port));
|
||||
if !changed {
|
||||
match saved {
|
||||
Some(saved) => save_snapshot(snapshot_path, &saved)?,
|
||||
None => remove_snapshot(snapshot_path)?,
|
||||
}
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Disabled services too: one disabled since it was pointed at us would point at us again
|
||||
// once re-enabled.
|
||||
let services = read_services(ns, true)?;
|
||||
let mut snapshot = load_snapshot(snapshot_path)?;
|
||||
let (commands, used_up) = disable_commands(&services, snapshot.as_ref(), port);
|
||||
if commands.is_empty() && !used_up {
|
||||
return Ok(());
|
||||
}
|
||||
// Saved first, so a restore cut short is finished by the next one.
|
||||
if let Some(snapshot) = snapshot.as_mut() {
|
||||
snapshot.mark_restoring(&commands, &services);
|
||||
save_snapshot(snapshot_path, snapshot)?;
|
||||
}
|
||||
if !commands.is_empty() {
|
||||
run(&commands)?;
|
||||
}
|
||||
match snapshot {
|
||||
Some(snapshot) if used_up => match snapshot.leftover(&services) {
|
||||
// Kept for when the service is back, and merged into the next capture.
|
||||
Some(leftover) => {
|
||||
let names: Vec<&str> = leftover.services.iter().map(|s| s.name.as_str()).collect();
|
||||
log::warn!("Keeping saved proxy settings no service matched: {}", names.join(", "));
|
||||
save_snapshot(snapshot_path, &leftover)
|
||||
}
|
||||
None => remove_snapshot(snapshot_path),
|
||||
},
|
||||
Some(mut snapshot) => {
|
||||
snapshot.restoring.clear();
|
||||
save_snapshot(snapshot_path, &snapshot)
|
||||
}
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The calls as one `sh` script that stops at the first failure.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn script(commands: &[Vec<String>]) -> String {
|
||||
commands
|
||||
.iter()
|
||||
.map(|args| {
|
||||
std::iter::once(NETWORKSETUP.to_string())
|
||||
.chain(args.iter().map(|a| shell_quote(a)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" && ")
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn shell_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
/// `script` inside an AppleScript string literal.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn applescript_string(s: &str) -> String {
|
||||
format!("\"{}\"", s.replace('\\', r"\\").replace('"', "\\\""))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
|
||||
fn check(output: &Output, program: &str) -> Result<(), String> {
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if stderr.is_empty() {
|
||||
format!("{program} exited with {}", output.status)
|
||||
} else {
|
||||
stderr
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run_privileged(script: &str, backend: SystemProxyBackend) -> Result<(), String> {
|
||||
match backend {
|
||||
SystemProxyBackend::Helper => {
|
||||
Err("The helper changes the system proxy itself, not by script".to_string())
|
||||
}
|
||||
SystemProxyBackend::AdminPrompt => {
|
||||
let source = format!(
|
||||
"do shell script {} with prompt {} with administrator privileges",
|
||||
applescript_string(script),
|
||||
applescript_string("Yaak Proxy wants to change your system proxy settings."),
|
||||
);
|
||||
let output = Command::new("/usr/bin/osascript")
|
||||
.args(["-e", &source])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run osascript: {e}"))?;
|
||||
// -128 is the dialog's Cancel button.
|
||||
if String::from_utf8_lossy(&output.stderr).contains("(-128)") {
|
||||
return Err("Cancelled in the password dialog".to_string());
|
||||
}
|
||||
check(&output, "osascript")
|
||||
}
|
||||
SystemProxyBackend::Sudo => {
|
||||
// sudo asks on the terminal, so the daemon's own stdin stays attached.
|
||||
let output = Command::new("/usr/bin/sudo")
|
||||
.args(["/bin/sh", "-c", script])
|
||||
.stdin(Stdio::inherit())
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run sudo: {e}"))?;
|
||||
check(&output, "sudo")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which services point where, judged against `port`.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn status(
|
||||
port: u16,
|
||||
backend: SystemProxyBackend,
|
||||
helper: HelperState,
|
||||
) -> Result<SystemProxyStatus, String> {
|
||||
let ns = yaak_proxy_helper::networksetup::SystemNetworksetup;
|
||||
let services = read_services(&ns, false)?;
|
||||
let enabled = !services.is_empty()
|
||||
&& services.iter().all(|s| s.http.points_at(port) && s.https.points_at(port));
|
||||
Ok(SystemProxyStatus { enabled, port, services, backend, helper, supported: true })
|
||||
}
|
||||
|
||||
/// Points every enabled service at `127.0.0.1:<port>`, or puts back the settings that were
|
||||
/// there, turning off any other proxy still pointing at `port`. Turning off with nothing
|
||||
/// pointing at `port` asks for nothing. `snapshot_path` is where the password dialog and
|
||||
/// `sudo` keep the settings they replace.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn set(
|
||||
enabled: bool,
|
||||
port: u16,
|
||||
backend: SystemProxyBackend,
|
||||
snapshot_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
if port == 0 {
|
||||
return Err("The system proxy needs a port".to_string());
|
||||
}
|
||||
match backend {
|
||||
SystemProxyBackend::Helper => crate::helper::set(enabled, port),
|
||||
SystemProxyBackend::AdminPrompt | SystemProxyBackend::Sudo => set_scripted(
|
||||
&yaak_proxy_helper::networksetup::SystemNetworksetup,
|
||||
|commands| imp::run_privileged(&script(commands), backend),
|
||||
enabled,
|
||||
port,
|
||||
snapshot_path,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn status(
|
||||
port: u16,
|
||||
backend: SystemProxyBackend,
|
||||
helper: HelperState,
|
||||
) -> Result<SystemProxyStatus, String> {
|
||||
Ok(SystemProxyStatus {
|
||||
enabled: false,
|
||||
port,
|
||||
services: Vec::new(),
|
||||
backend,
|
||||
helper,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn set(
|
||||
enabled: bool,
|
||||
_port: u16,
|
||||
_backend: SystemProxyBackend,
|
||||
_snapshot_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
if !enabled {
|
||||
return Ok(());
|
||||
}
|
||||
Err("Setting the system proxy is only supported on macOS".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use yaak_proxy_helper::networksetup::HOST;
|
||||
use yaak_proxy_helper::networksetup::fake::FakeNetworksetup;
|
||||
|
||||
fn setting(enabled: bool, server: &str, port: u16) -> ProxySetting {
|
||||
ProxySetting { enabled, server: server.into(), port }
|
||||
}
|
||||
|
||||
/// Runs the "privileged" commands against the fake, counting the prompts.
|
||||
fn runner<'a>(
|
||||
ns: &'a FakeNetworksetup,
|
||||
prompts: &'a std::cell::Cell<usize>,
|
||||
) -> impl FnOnce(&[Vec<String>]) -> Result<(), String> + 'a {
|
||||
move |commands| {
|
||||
prompts.set(prompts.get() + 1);
|
||||
yaak_proxy_helper::networksetup::apply(ns, commands)
|
||||
}
|
||||
}
|
||||
|
||||
fn fake() -> FakeNetworksetup {
|
||||
FakeNetworksetup::new(vec![
|
||||
("Wi-Fi", setting(true, "corp-proxy", 3128), setting(false, "corp-proxy", 3129)),
|
||||
("Ethernet", ProxySetting::default(), ProxySetting::default()),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_password_path_restores_what_it_replaced() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(SNAPSHOT_FILE);
|
||||
let ns = fake();
|
||||
let before = ns.proxies();
|
||||
let prompts = std::cell::Cell::new(0);
|
||||
|
||||
set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap();
|
||||
assert!(ns.proxies().iter().all(|s| s.http.points_at(9090) && s.https.points_at(9090)));
|
||||
assert!(load_snapshot(&path).unwrap().is_some());
|
||||
|
||||
set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap();
|
||||
assert_eq!(ns.proxies()[0], before[0]);
|
||||
assert!(!ns.proxies()[1].points_at(9090));
|
||||
assert!(!path.exists());
|
||||
assert_eq!(prompts.get(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_reset_after_a_crash_uses_the_snapshot() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(SNAPSHOT_FILE);
|
||||
let ns = fake();
|
||||
let before = ns.proxies();
|
||||
let prompts = std::cell::Cell::new(0);
|
||||
set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap();
|
||||
|
||||
// The app crashed; the next launch resets the configured port, now another one, which
|
||||
// leaves the snapshot alone, and then the port it saved.
|
||||
assert_eq!(saved_port(&path), Some(9090));
|
||||
set_scripted(&ns, runner(&ns, &prompts), false, 8080, &path).unwrap();
|
||||
assert!(path.exists());
|
||||
set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap();
|
||||
assert_eq!(ns.proxies()[0], before[0]);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_reset_at_an_old_port_leaves_live_routing_alone() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(SNAPSHOT_FILE);
|
||||
let ns = fake();
|
||||
let before = ns.proxies();
|
||||
let prompts = std::cell::Cell::new(0);
|
||||
// A crash left a disabled service on 9090; the new engine routes on 9191.
|
||||
set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap();
|
||||
ns.set_enabled("Ethernet", false);
|
||||
set_scripted(&ns, runner(&ns, &prompts), true, 9191, &path).unwrap();
|
||||
|
||||
set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap();
|
||||
assert!(ns.proxies()[0].http.points_at(9191), "live routing kept");
|
||||
assert!(!ns.proxies()[1].points_at(9090), "the stale port cleared");
|
||||
assert!(path.exists());
|
||||
|
||||
set_scripted(&ns, runner(&ns, &prompts), false, 9191, &path).unwrap();
|
||||
assert_eq!(ns.proxies()[0], before[0]);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_password_path_restores_a_renamed_service_and_keeps_an_unplugged_one() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(SNAPSHOT_FILE);
|
||||
let ns = fake();
|
||||
let before = ns.proxies();
|
||||
let prompts = std::cell::Cell::new(0);
|
||||
set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap();
|
||||
ns.rename("Wi-Fi", "Office Wi-Fi");
|
||||
ns.remove("Ethernet");
|
||||
|
||||
set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap();
|
||||
let wifi = &ns.proxies()[0];
|
||||
assert_eq!((&wifi.http, &wifi.https), (&before[0].http, &before[0].https));
|
||||
// Ethernet's settings stay saved, without counting as routing.
|
||||
let kept = load_snapshot(&path).unwrap().unwrap();
|
||||
assert_eq!(kept.services.len(), 1);
|
||||
assert_eq!(kept.services[0].name, "Ethernet");
|
||||
assert_eq!(saved_port(&path), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_partly_applied_change_keeps_its_snapshot() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(SNAPSHOT_FILE);
|
||||
let ns = fake();
|
||||
let before = ns.proxies();
|
||||
let prompts = std::cell::Cell::new(0);
|
||||
// Wi-Fi is changed, then Ethernet fails.
|
||||
*ns.failing.lock().unwrap() = Some("Ethernet".into());
|
||||
assert!(set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).is_err());
|
||||
assert!(ns.proxies()[0].points_at(9090));
|
||||
assert!(path.exists());
|
||||
|
||||
*ns.failing.lock().unwrap() = None;
|
||||
set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap();
|
||||
assert_eq!(ns.proxies()[0], before[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turning_off_with_nothing_pointing_at_us_asks_for_nothing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(SNAPSHOT_FILE);
|
||||
let ns = fake();
|
||||
let prompts = std::cell::Cell::new(0);
|
||||
set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap();
|
||||
assert_eq!(prompts.get(), 0);
|
||||
assert!(ns.changes.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_prompt_keeps_no_snapshot() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(SNAPSHOT_FILE);
|
||||
let ns = fake();
|
||||
let cancel = |_: &[Vec<String>]| Err("Cancelled in the password dialog".to_string());
|
||||
assert!(set_scripted(&ns, cancel, true, 9090, &path).is_err());
|
||||
assert!(!path.exists());
|
||||
assert!(ns.changes.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turning_on_again_keeps_the_first_snapshot() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(SNAPSHOT_FILE);
|
||||
let ns = fake();
|
||||
let before = ns.proxies();
|
||||
let prompts = std::cell::Cell::new(0);
|
||||
set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap();
|
||||
// A second enable, the engine on a new port, must not save ours as the user's.
|
||||
set_scripted(&ns, runner(&ns, &prompts), true, 9191, &path).unwrap();
|
||||
let cancel = |_: &[Vec<String>]| Err("Cancelled in the password dialog".to_string());
|
||||
assert!(set_scripted(&ns, cancel, true, 9191, &path).is_err());
|
||||
assert!(path.exists(), "a cancelled re-enable keeps the original snapshot");
|
||||
set_scripted(&ns, runner(&ns, &prompts), false, 9191, &path).unwrap();
|
||||
assert_eq!(ns.proxies()[0], before[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disabled_service_pointing_at_us_is_turned_off() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(SNAPSHOT_FILE);
|
||||
let ns = fake();
|
||||
let prompts = std::cell::Cell::new(0);
|
||||
set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap();
|
||||
ns.set_enabled("Ethernet", false);
|
||||
set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap();
|
||||
let ethernet = &ns.proxies()[1];
|
||||
assert!(!ethernet.points_at(9090));
|
||||
assert_eq!(ethernet.http.server, HOST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quotes_service_names() {
|
||||
let commands = vec![vec![
|
||||
"-setwebproxystate".to_string(),
|
||||
"Bob's \"LAN\"".to_string(),
|
||||
"off".into(),
|
||||
]];
|
||||
let script = script(&commands);
|
||||
assert_eq!(script, r#"/usr/sbin/networksetup '-setwebproxystate' 'Bob'\''s "LAN"' 'off'"#);
|
||||
assert_eq!(
|
||||
applescript_string(&script),
|
||||
r#""/usr/sbin/networksetup '-setwebproxystate' 'Bob'\\''s \"LAN\"' 'off'""#
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -53,9 +53,24 @@ pub struct Flow {
|
||||
/// Milliseconds from `started_at` to the end of the response.
|
||||
pub elapsed_ms: Option<i32>,
|
||||
pub error: Option<String>,
|
||||
/// The client gave up before the response ended, by resetting its HTTP/2 stream or closing
|
||||
/// the connection. Not an error.
|
||||
pub cancelled: bool,
|
||||
/// The id of the Yaak request this flow was last saved as.
|
||||
pub saved_request_id: Option<String>,
|
||||
/// The process that opened the client connection, where the platform can tell (macOS).
|
||||
pub client_pid: Option<i32>,
|
||||
/// Its app's name, or its executable's outside an app: "Google Chrome", "curl".
|
||||
pub client_name: Option<String>,
|
||||
/// Its app's bundle id.
|
||||
pub client_bundle_id: Option<String>,
|
||||
/// Its app bundle's path, or its executable's outside an app.
|
||||
pub client_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub enum FlowBody {
|
||||
Request,
|
||||
Response,
|
||||
@@ -143,6 +158,12 @@ impl UpsertModelInfo for Flow {
|
||||
(ElapsedHeadersMs, self.elapsed_headers_ms.into()),
|
||||
(ElapsedMs, self.elapsed_ms.into()),
|
||||
(Error, self.error.into()),
|
||||
(Cancelled, self.cancelled.into()),
|
||||
(SavedRequestId, self.saved_request_id.into()),
|
||||
(ClientPid, self.client_pid.into()),
|
||||
(ClientName, self.client_name.into()),
|
||||
(ClientBundleId, self.client_bundle_id.into()),
|
||||
(ClientPath, self.client_path.into()),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -166,6 +187,12 @@ impl UpsertModelInfo for Flow {
|
||||
FlowIden::ElapsedHeadersMs,
|
||||
FlowIden::ElapsedMs,
|
||||
FlowIden::Error,
|
||||
FlowIden::Cancelled,
|
||||
FlowIden::SavedRequestId,
|
||||
FlowIden::ClientPid,
|
||||
FlowIden::ClientName,
|
||||
FlowIden::ClientBundleId,
|
||||
FlowIden::ClientPath,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -196,6 +223,20 @@ impl UpsertModelInfo for Flow {
|
||||
elapsed_headers_ms: r.get("elapsed_headers_ms")?,
|
||||
elapsed_ms: r.get("elapsed_ms")?,
|
||||
error: r.get("error")?,
|
||||
cancelled: added_column(r, "cancelled")?.unwrap_or_default(),
|
||||
saved_request_id: added_column(r, "saved_request_id")?,
|
||||
client_pid: added_column(r, "client_pid")?,
|
||||
client_name: added_column(r, "client_name")?,
|
||||
client_bundle_id: added_column(r, "client_bundle_id")?,
|
||||
client_path: added_column(r, "client_path")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A column added by a later migration, absent from a database read before it has run.
|
||||
fn added_column<T: rusqlite::types::FromSql>(r: &Row, name: &str) -> rusqlite::Result<Option<T>> {
|
||||
match r.get(name) {
|
||||
Err(rusqlite::Error::InvalidColumnName(_)) => Ok(None),
|
||||
v => v,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Saving a flow into the Yaak client's database. It is opened as a lifecycle guest, as the CLI
|
||||
//! opens it, since Yaak may have it open too, and the request is written with
|
||||
//! `UpdateSource::Sync` so a running Yaak picks it up from its model-change poller.
|
||||
|
||||
use crate::bodies::DecodedBody;
|
||||
use crate::convert::{self, DroppedHeader};
|
||||
use crate::models::Flow;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use ts_rs::TS;
|
||||
use yaak_models::models::HttpRequest;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
/// Yaak's data directory, where its `db.sqlite` is. A debug build pairs with a debug Yaak.
|
||||
pub fn client_data_dir() -> PathBuf {
|
||||
let app_id = if cfg!(debug_assertions) { "app.yaak.desktop.dev" } else { "app.yaak.desktop" };
|
||||
yaak_common::data_dir::resolve_data_dir(app_id)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct WorkspaceSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub folders: Vec<FolderSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct FolderSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// The folder this one is in, if any.
|
||||
pub folder_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveFlowRequest {
|
||||
pub workspace_id: String,
|
||||
pub folder_id: Option<String>,
|
||||
/// None or blank leaves the request unnamed, and Yaak shows its URL.
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveFlowResponse {
|
||||
pub request: HttpRequest,
|
||||
pub dropped_headers: Vec<DroppedHeader>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct ClientDb {
|
||||
query_manager: QueryManager,
|
||||
}
|
||||
|
||||
impl ClientDb {
|
||||
/// Yaak has run on this machine, so there is a database to save into.
|
||||
pub fn exists(data_dir: &Path) -> bool {
|
||||
data_dir.join("db.sqlite").exists()
|
||||
}
|
||||
|
||||
/// Opens `db.sqlite` in Yaak's data directory `data_dir`. Fails when there is none, rather
|
||||
/// than creating a database for a Yaak that has never run.
|
||||
pub fn open(data_dir: &Path) -> Result<Self, String> {
|
||||
let db_path = data_dir.join("db.sqlite");
|
||||
if !Self::exists(data_dir) {
|
||||
return Err(format!(
|
||||
"Yaak's database was not found at {}. Install Yaak and open it once.",
|
||||
db_path.display()
|
||||
));
|
||||
}
|
||||
let (query_manager, blob_manager, _rx) =
|
||||
yaak_models::init_standalone(&db_path, data_dir.join("blobs.sqlite"))
|
||||
.map_err(|e| format!("Failed to open Yaak's database: {e}"))?;
|
||||
query_manager
|
||||
.with_tx(|tx| {
|
||||
yaak_lifecycle::on_launch(&yaak_lifecycle::Host::guest(), tx, &blob_manager)
|
||||
})
|
||||
.map_err(|e| format!("Failed to open Yaak's database: {e}"))?;
|
||||
Ok(Self { query_manager })
|
||||
}
|
||||
|
||||
pub fn workspaces(&self) -> Result<Vec<WorkspaceSummary>, String> {
|
||||
let db = self.query_manager.connect();
|
||||
let workspaces = db.list_workspaces().map_err(|e| e.to_string())?;
|
||||
workspaces
|
||||
.into_iter()
|
||||
.map(|w| {
|
||||
let folders = db.list_folders(&w.id).map_err(|e| e.to_string())?;
|
||||
Ok(WorkspaceSummary {
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
folders: folders
|
||||
.into_iter()
|
||||
.map(|f| FolderSummary { id: f.id, name: f.name, folder_id: f.folder_id })
|
||||
.collect(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Writes `flow`, with `body` its decoded request body, as a new request.
|
||||
pub fn save(
|
||||
&self,
|
||||
flow: &Flow,
|
||||
body: &DecodedBody,
|
||||
target: &SaveFlowRequest,
|
||||
) -> Result<SaveFlowResponse, String> {
|
||||
let db = self.query_manager.connect();
|
||||
db.get_workspace(&target.workspace_id)
|
||||
.map_err(|_| format!("No workspace {} in Yaak", target.workspace_id))?;
|
||||
if let Some(folder_id) = &target.folder_id {
|
||||
let folder =
|
||||
db.get_folder(folder_id).map_err(|_| format!("No folder {folder_id} in Yaak"))?;
|
||||
if folder.workspace_id != target.workspace_id {
|
||||
return Err(format!(
|
||||
"Folder {folder_id} is not in workspace {}",
|
||||
target.workspace_id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let conversion = convert::flow_to_request(flow, body);
|
||||
let mut request = conversion.request;
|
||||
request.workspace_id = target.workspace_id.clone();
|
||||
request.folder_id = target.folder_id.clone();
|
||||
if let Some(name) = target.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) {
|
||||
request.name = name.to_string();
|
||||
}
|
||||
// First in its folder, as the client places a new request.
|
||||
request.sort_priority = -(chrono::Utc::now().timestamp_millis() as f64);
|
||||
|
||||
let request = self
|
||||
.query_manager
|
||||
.with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to save the request to Yaak: {e}"))?;
|
||||
Ok(SaveFlowResponse {
|
||||
request,
|
||||
dropped_headers: conversion.dropped_headers,
|
||||
warnings: conversion.warnings,
|
||||
})
|
||||
}
|
||||
}
|
||||
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user