Compare commits

...
Author SHA1 Message Date
Gregory SchierandClaude Opus 5.5 cd431186b6 Patch the Brotli decoder CVE in bundled Node and yaak-http
CVE-2026-111497 is an out-of-bounds write in the Brotli decoder. It
reaches Yaak through the bundled Node (`yaaknode`, used by plugins) and
through the pure-Rust `brotli-decompressor` that yaak-http uses to decode
`br` responses.

- Bump the plugin runtime Node from 24.11.1 to 24.22.2 and update the
  vendoring script's SHA256 pins from the signed SHASUMS256.txt.
- Move yaak-http from `brotli = "8"` to `brotli = "9"`, locking brotli
  9.1.1 and brotli-decompressor 6.2.0.

Tauri's pinned git rev still pulls brotli 8.0.1 and brotli-decompressor
5.0.0 for its embedded-asset compression; that needs a Tauri bump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:01:25 -07:00
Gregory SchierandClaude Opus 5.5 f7aea62c07 Privileged helper for system proxy (#796)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:41:00 -07:00
Gregory SchierandClaude Opus 5.5 4d38a482f6 HTTP/2 on the client side (#797)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:31:49 -07:00
Gregory SchierandClaude Opus 5.5 e82ffc53ad Proxy GUI pass (#793)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:27:18 -07:00
Gregory SchierandClaude Opus 5.5 96ab0a8a60 Give empty sidebar nodes a collapsed flag too
The shared tree treats every node with a children list as a folder and
collapses an empty one before the left arrow moves to its parent. Left
out of the collapsed map, a leaf path never read as collapsed, so the
left arrow kept trying to collapse it and never moved up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:44:22 -07:00
Gregory SchierandClaude Opus 5.5 974b9de673 Start the proxy sidebar's hosts and paths collapsed
Every domain and path node starts collapsed, so paths stay hidden until
a host is expanded. The nodes the user expands are kept by id in local
storage, so they stay open across launches; hosts seen later start
collapsed. The Domains folder, selection and counts are as before. The
shared tree is unchanged: the proxy hands it its own collapsed atom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:40:30 -07:00
Gregory SchierandClaude Opus 5.5 e33aeee5ff Close the clear and count races the second review found
- Send a flow's write event inside its transaction, so a clear's event,
  sent after its own commit, always comes after it.
- Record a saved request only on a flow that still exists, so a save
  that finishes after a clear does not bring the flow back.
- Count the total again from the database a second after new flows,
  so a flow both in a page and in the events around it settles to the
  right count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:34:14 -07:00
Gregory SchierandClaude Opus 5.5 39a7397ebb Close three races the review found in the flow window
- Replay live changes over a page of older flows, so a slow flow that
  ends while the page loads is not lost from the window.
- Read list_flows' page and total from one snapshot, so a flow written
  between them is not counted twice.
- Commit a clear's flow deletion only after its bodies' deletion, so a
  failure leaves the flows to clear again rather than orphaned bodies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:04:25 -07:00
Gregory SchierandClaude Opus 5.5 1a994f7532 Keep the proxy app fast with many flows
The app held every flow in memory and redid all of its work for each new
one: the sidebar mounted every domain and path node (its tree scrolled
inside a container that never bounded it), every tree row looked up its
atoms by scanning every atom the family had made, and the layout, detail
pane and list rows re-rendered per flow. At 3,000 flows each new one cost
about 90 ms of main thread, more than the traffic left time for.

The app now holds a window of the newest 2,000 flows, applies new ones in
batches, and loads older ones in pages from the database on scrolling to
the top, through a new list_flows RPC that pages in SQL. The database
keeps every flow.

Also adds clear_flows: an RPC, POST /flows/clear on the control API, and
a Clear button in the header that Cmd+K opens, confirming first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:52:00 -07:00
Gregory SchierandClaude Opus 5.5 4abc882e13 Send the client's Host as :authority to HTTP/2 upstreams
Dropping Host lost a virtual host that differs from the CONNECT target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:38:54 -07:00
Gregory SchierandClaude Opus 5.5 11aca4539d Fix three proxy engine bugs found while dogfooding
- Rebuild a bare-path plain-HTTP request's URL from Host, answer 400
  "no host in request" when there is none, and refuse with 508 a rebuilt
  request that is routed back into the proxy, which would otherwise loop
  without end.
- Drop Host when forwarding to an HTTP/2 upstream. Google's servers reset
  every stream that carries Host alongside :authority.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:35:17 -07:00
Gregory SchierandClaude Opus 5.5 9bbd28ce4a PR 2: proxy services (#787)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:19:50 -07:00
Gregory SchierandClaude Opus 5.5 686294939b Rescue the proxy GUI slice (#785)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 06:38:34 -07:00
Gregory Schier 6132171791 Agents pane: CLI-only MCP, yaak mcp --http, and plugin deprecation (#761) 2026-10-25 23:14:45 -07:00
176 changed files with 19821 additions and 3395 deletions

No files matched your search

+1
View File
@@ -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
View File
@@ -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]]
+2
View File
@@ -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" }
+11 -52
View File
@@ -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()],
+4 -41
View File
@@ -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} />;
+2 -72
View File
@@ -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";
+2 -200
View File
@@ -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
View File
@@ -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";
+2 -242
View File
@@ -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";
+2 -77
View File
@@ -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;
+2 -148
View File
@@ -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
View File
@@ -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";
+43 -84
View File
@@ -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[]) {
+17 -1
View File
@@ -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
View File
@@ -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;
}
+24 -34
View File
@@ -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");
});
});
+21 -56
View File
@@ -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
View File
@@ -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";
+2 -36
View File
@@ -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(" / ");
+2 -137
View File
@@ -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";
+13 -38
View File
@@ -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>({
+20 -12
View File
@@ -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

+49
View File
@@ -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;
}
+225
View File
@@ -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>
);
}
+322
View File
@@ -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>&bull;</span>
<span
title={
flow.elapsedHeadersMs == null
? undefined
: `HEADERS: ${formatMillis(flow.elapsedHeadersMs)}\nTOTAL: ${formatMillis(flow.elapsedMs)}`
}
>
{formatMillis(flow.elapsedMs)}
</span>
</>
)}
{!noResponse && (
<>
<span>&bull;</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>
);
}
+109
View File
@@ -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>;
}
+412
View File
@@ -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>
);
}
+78 -47
View File
@@ -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"} />
)}
/>
);
}
+480
View File
@@ -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>
);
}
+320
View File
@@ -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";
}
+111
View File
@@ -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>
);
});
+127 -104
View File
@@ -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 -1
View File
@@ -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";
+24
View File
@@ -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");
+20
View File
@@ -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");
});
});
+87
View File
@@ -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;
}
+94
View File
@@ -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();
});
});
+68
View File
@@ -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 ")}`;
}
+19
View File
@@ -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"),
);
});
});
+21
View File
@@ -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");
}
+121
View File
@@ -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],
);
}
+142
View File
@@ -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"]);
});
});
+100
View File
@@ -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;
}
+28
View File
@@ -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);
});
});
+17
View File
@@ -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;
}
+85 -33
View File
@@ -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],
);
}
+6 -1
View File
@@ -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. */
+143
View File
@@ -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();
});
});
+116
View File
@@ -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);
});
});
+62
View File
@@ -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 };
}
+41
View File
@@ -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;
}
+353 -8
View File
@@ -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", {}));
}
+161
View File
@@ -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");
});
});
+135
View File
@@ -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";
}
+11
View File
@@ -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();
+12
View File
@@ -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],
);
}
+34
View File
@@ -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);
});
});
+25
View File
@@ -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,
};
}
+1
View File
@@ -12,6 +12,7 @@
:root {
--font-family-interface: "";
--font-family-editor: "";
--editor-font-size: 12px;
}
:root {
+16 -20
View File
@@ -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>,
+1
View File
@@ -0,0 +1 @@
declare module "vkbeautify";
+5 -3
View File
@@ -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",
+17 -2
View File
@@ -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)]
+47 -2
View File
@@ -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 {
+3 -2
View File
@@ -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
}
+126
View File
@@ -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();
}
+47 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+10 -8
View File
@@ -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
+104 -11
View File
@@ -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);
}
}
+75
View File
@@ -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())
}
+235
View File
@@ -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
}
}
+632
View File
@@ -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
}
+462
View File
@@ -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&note=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."]
);
}
}
+89 -4
View File
@@ -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");
}
}
+795
View File
@@ -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)
);
}
}
+422
View File
@@ -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");
}
}
+222
View File
@@ -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
+495
View File
@@ -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'""#
);
}
}
+42 -1
View File
@@ -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,
}
}
+148
View File
@@ -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