Detail card, settings window and small fixes for Yaak Proxy (#824)

This commit is contained in:
Gregory Schier authored and GitHub committed 2026-11-07 13:45:39 -07:00
1 parent 4538123d88
commit 2ca2bc5f00
75 files changed
+4574 -1147

No files matched your search

+3
View File
@@ -59,3 +59,6 @@ flatpak/node-sources.json
# Claude Code local settings
.claude/settings.local.json
.claude/worktrees/
# Headless screenshots taken while working
.shots/
@@ -1,10 +1,9 @@
import { Prec } from "@codemirror/state";
import { type EditorView, keymap } from "@codemirror/view";
import type { DecryptListEntry, SeenTargets } from "@yaakapp-internal/proxy-lib";
import { Dialog, Icon, IconButton, Input } from "@yaakapp-internal/ui";
import { Icon, IconButton, Input } from "@yaakapp-internal/ui";
import { filterAutocomplete } from "@yaakapp-internal/ui/editor";
import classNames from "classnames";
import { atom, useAtom } from "jotai";
import { useMemo, useRef, useState } from "react";
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
import { useRpcQuery } from "../hooks/useRpcQuery";
@@ -13,8 +12,8 @@ import { decryptFilterOptions, entryLabel, normalizeEntry } from "../lib/decrypt
/**
* What HTTPS is decrypted: one row per entry, the user's in order and then the built-in
* exclusions, each removable, and an input that adds one, suggesting `host:`, `app:` and `!`,
* then the hosts and apps seen in the capture. Saves as it changes. Placed by Settings; until
* that exists, by the dialog the state pill's More… opens.
* then the hosts and apps seen in the capture. Saves as it changes. Placed by Settings' Proxy
* tab.
*/
export function DecryptListEditor({ className }: { className?: string }) {
const list = useDecryptList();
@@ -167,21 +166,3 @@ function EntryInput({
</div>
);
}
/** Opens the decrypt list from the state pill's More…, until Settings holds it */
export const decryptListDialogOpenAtom = atom(false);
export function DecryptListDialog() {
const [open, setOpen] = useAtom(decryptListDialogOpenAtom);
return (
<Dialog
open={open}
onClose={() => setOpen(false)}
size="dynamic"
title="Decrypt List"
className="w-[36rem]"
>
<div className="pb-4">{open && <DecryptListEditor />}</div>
</Dialog>
);
}
+307
View File
@@ -0,0 +1,307 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow, FlowBody } from "@yaakapp-internal/proxy-lib";
import {
CountBadge,
Dropdown,
EmptyStateText,
type ExtraPaneTab,
HttpRequestPaneView,
HttpResponsePaneView,
type HttpBodyResult,
Icon,
IconButton,
parseCookieHeaders,
ResponseCookies,
type ResponseViewMode,
SplitLayout,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useSetAtom } from "jotai";
import { useCallback, useMemo, useState } from "react";
import { BODY_TYPE_BINARY } from "@yaakapp-internal/ui/httpTypes";
import { findHeader } from "../lib/body";
import {
browserTabMemory,
defaultTabFor,
type FlowPane,
learnTab,
REQUEST_TAB_AUTH,
REQUEST_TAB_BODY,
REQUEST_TAB_COOKIES,
REQUEST_TAB_HEADERS,
REQUEST_TAB_PARAMS,
RESPONSE_TAB_BODY,
RESPONSE_TAB_COOKIES,
RESPONSE_TAB_HEADERS,
RESPONSE_TAB_INFO,
RESPONSE_TAB_MESSAGES,
RESPONSE_TAB_TIMELINE,
} from "../lib/defaultTab";
import { saveDialogFlowAtom, useFlowActions } from "../lib/flowActions";
import {
type FlowBodyData,
flowBodyLoader,
flowCookiesView,
flowRequestToView,
flowResponseToView,
} from "../lib/flowViews";
import { messagesTabLabel } from "../lib/messages";
import { FlowBodyViewer, useFlowBody } from "./FlowBodyViewer";
import { FlowInfo } from "./FlowInfo";
import { FlowRequestBody } from "./FlowRequestBody";
import { FlowMessages } from "./FlowMessages";
import { FlowTimeline } from "./FlowTimeline";
import { ProxyEditorHost } from "./ProxyEditorHost";
/**
* One card for a flow: the request half (its URL line and tabs), a hairline, and the response
* half (its status line and tabs). The halves sit perpendicular to the list: stacked when the
* list is beside the card, side by side when it is above.
*/
export function FlowCard({ flow, listBeside }: { flow: Flow; listBeside: boolean }) {
return (
<ProxyEditorHost>
<SplitLayout
storageKey={listBeside ? "proxy_card_stacked" : "proxy_card_side"}
layout={listBeside ? "vertical" : "horizontal"}
minHeightPx={120}
minWidthPx={240}
firstSlot={({ style }) => (
<div style={style}>
<FlowRequestHalf flow={flow} />
</div>
)}
secondSlot={({ style, orientation }) => (
<div style={style}>
<FlowResponseHalf
flow={flow}
className={orientation === "vertical" ? "border-t" : "border-l"}
/>
</div>
)}
/>
</ProxyEditorHost>
);
}
/** The tab a half opens on, and learning from the ones chosen by hand */
function useShapeTab(pane: FlowPane, flow: Flow, available: string[]) {
const memory = browserTabMemory();
// Read again for every flow, so a tab learned on one opens the next of its shape
const defaultTab = defaultTabFor(pane, flow, available, memory);
const onTabChange = useCallback(
(tab: string) => learnTab(pane, flow, tab, memory),
[pane, flow, memory],
);
return { defaultTab, onTabChange };
}
/** A body read through the engine's body RPC, as the shared panes' loaders ask for it */
function useLoadedBody(
flow: Flow,
which: FlowBody,
enabled = true,
): HttpBodyResult<FlowBodyData | null> {
const size = which === "request" ? flow.requestSize : flow.responseSize;
const load = enabled && size > 0;
const body = useFlowBody(flow, which, load);
if (!load) return { data: null, isLoading: false, error: null };
return { data: body.data ?? undefined, isLoading: body.isLoading, error: body.error };
}
function FlowRequestHalf({ flow }: { flow: Flow }) {
const body = useLoadedBody(flow, "request");
const request = useMemo(() => flowRequestToView(flow, body.data ?? null), [flow, body.data]);
const loader = useMemo(
() =>
flowBodyLoader({
useBody: () => useLoadedBody(flow, "request"),
contentType: findHeader(flow.requestHeaders, "content-type"),
}),
[flow],
);
const cookies = useMemo(() => flowCookiesView(flow), [flow]);
const sentCount = useMemo(
() => parseCookieHeaders(cookies.cookieHeaders).sentCookies.length,
[cookies],
);
const extraTabs = useMemo<ExtraPaneTab[]>(
() => [
{
tab: {
value: REQUEST_TAB_COOKIES,
label: "Cookies",
empty: sentCount === 0,
rightSlot: <CountBadge count={sentCount} />,
},
content: <ResponseCookies response={cookies} side="sent" />,
},
],
[cookies, sentCount],
);
const available = [
REQUEST_TAB_BODY,
REQUEST_TAB_PARAMS,
REQUEST_TAB_HEADERS,
...(request.auth == null ? [] : [REQUEST_TAB_AUTH]),
REQUEST_TAB_COOKIES,
];
const { defaultTab, onTabChange } = useShapeTab("request", flow, available);
return (
<HttpRequestPaneView
className="px-3"
request={request}
fullHeight
dimEmptyTabs
forceUpdateKey={`${flow.updatedAt}.${body.data == null ? "" : "loaded"}`}
tabsStorageKey="proxy_request_tabs"
bodyLoader={loader}
extraTabs={extraTabs}
defaultTab={defaultTab}
onTabChange={onTabChange}
urlLine={<UrlLine flow={flow} />}
renderBody={(editor) => (
<FlowRequestBody
body={body}
editor={editor}
// The proxy's own viewer, with its hexdump and save, rather than a size alone
binaryViewer={
request.bodyType === BODY_TYPE_BINARY ? (
<FlowBodyViewer flow={flow} which="request" pretty />
) : null
}
requestSize={flow.requestSize}
/>
)}
/>
);
}
/** The method and URL, at the status line's height so the two header lines align */
function UrlLine({ flow }: { flow: Flow }) {
return (
<div
className={classNames(
"flex items-center gap-2 min-h-[calc(var(--height-sm)+0.25rem)] -mb-1.5",
"font-mono text-sm whitespace-nowrap overflow-x-auto hide-scrollbars",
)}
>
<span className="text-text-subtle shrink-0">{flow.method}</span>
<span className="text-text select-text cursor-text" title={flow.url}>
{flow.url}
</span>
</div>
);
}
function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }) {
const [viewMode, setViewMode] = useState<ResponseViewMode>("pretty");
// A stream's body is its messages, read by the Messages tab; reading it whole again on every
// message would only hold copies of it
const loadBody = flow.streamKind == null;
const body = useLoadedBody(flow, "response", loadBody);
const response = useMemo(() => flowResponseToView(flow, body.data ?? null), [flow, body.data]);
const loader = useMemo(
() =>
flowBodyLoader({
useBody: () => useLoadedBody(flow, "response", loadBody),
contentType: findHeader(flow.responseHeaders, "content-type"),
}),
[flow, loadBody],
);
const openSave = useSetAtom(saveDialogFlowAtom);
const flowActions = useFlowActions();
const messagesLabel = messagesTabLabel(flow);
const extraTabs = useMemo<ExtraPaneTab[]>(
() => [
{
tab: { value: RESPONSE_TAB_TIMELINE, label: "Timeline" },
content: <FlowTimeline flow={flow} />,
},
{ tab: { value: RESPONSE_TAB_INFO, label: "Info" }, content: <FlowInfo flow={flow} /> },
...(messagesLabel == null
? []
: [
{
tab: {
value: RESPONSE_TAB_MESSAGES,
label: messagesLabel,
empty: flow.messageCount === 0,
rightSlot: (
<CountBadge
count={flow.messageCount}
color="info"
showZero
className="tabular-nums"
/>
),
},
content: <FlowMessages flow={flow} />,
},
]),
],
[flow, messagesLabel],
);
const available = [
RESPONSE_TAB_BODY,
RESPONSE_TAB_HEADERS,
RESPONSE_TAB_COOKIES,
RESPONSE_TAB_TIMELINE,
RESPONSE_TAB_INFO,
...(messagesLabel == null ? [] : [RESPONSE_TAB_MESSAGES]),
];
const { defaultTab, onTabChange } = useShapeTab("response", flow, available);
const saved = flow.savedRequestId != null;
return (
<HttpResponsePaneView
bare
requestShownApart
dimEmptyTabs
className={classNames(className, "border-border-subtle")}
response={response}
body={loader}
requestBody={null}
emptyState={<EmptyStateText>Waiting for a response</EmptyStateText>}
viewMode={viewMode}
onViewModeChange={setViewMode}
tabsStorageKey="proxy_response_tabs"
activeTabKey={flow.id}
defaultTab={defaultTab}
onTabChange={onTabChange}
extraTabs={extraTabs}
renderBodyViewer={() => (
<FlowBodyViewer flow={flow} which="response" pretty={viewMode === "pretty"} />
)}
statusLineCenter={
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" />
{body.data == null ? "Body cut" : `First ${formatSize(body.data.storedSize)} kept`}
</span>
) : undefined
}
statusLineRight={
<div className="flex items-center gap-0.5 m-0.5">
<IconButton
size="sm"
icon="bookmark"
iconColor={saved ? "primary" : "secondary"}
title={saved ? "Saved to Yaak" : "Save to Yaak"}
onClick={() => openSave(flow)}
/>
<Dropdown items={flowActions(flow).slice(2)}>
<IconButton size="sm" icon="ellipsis" iconColor="secondary" title="Actions" />
</Dropdown>
</div>
}
/>
);
}
+9 -298
View File
@@ -1,50 +1,22 @@
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,
LoadingIcon,
SizeTag,
type TabButtonRenderProps,
type TabItem,
TabContent,
Tabs,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue, useSetAtom } from "jotai";
import { useAtomValue } 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 { messagesTabLabel, streamIsOpen } from "../lib/messages";
import { EmptyState, FlowBodyViewer, useFlowBody } from "./FlowBodyViewer";
import { FlowHeaders, FlowQuery, queryParams } from "./FlowHeaders";
import { FlowMessages } from "./FlowMessages";
import { EmptyState } from "./FlowBodyViewer";
import { FlowCard } from "./FlowCard";
import { NotDecrypted } from "./NotDecrypted";
const TAB_RESPONSE = "response";
const TAB_REQUEST = "request";
const TAB_HEADERS = "headers";
const TAB_QUERY = "query";
const TAB_MESSAGES = "messages";
/**
* The detail pane: the selected flow's card, or what the pane holds before one is selected. Side
* by side, a wider gutter separates the card from the list and it sits a little under the header.
*/
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")}
className={classNames("min-h-0 min-w-0 pb-2", sideBySide ? "pr-2 pl-3 pt-3" : "px-2")}
>
<div
className={classNames(
@@ -66,274 +38,13 @@ export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideB
) : flow.tunnelled ? (
<NotDecrypted flow={flow} />
) : (
<FlowPane flow={flow} />
<FlowCard flow={flow} listBeside={sideBySide} />
)}
</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 messagesLabel = messagesTabLabel(flow);
const tabs = useMemo<TabItem[]>(
() => [
...(messagesLabel == null
? []
: [
{
value: TAB_MESSAGES,
label: messagesLabel,
rightSlot: <CountBadge count={flow.messageCount} color="info" showZero />,
},
]),
{
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 />,
},
],
[
messagesLabel,
flow.messageCount,
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">
{streamIsOpen(flow) && <LoadingIcon size="sm" />}
{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
// A stream opens on its messages; moving between streams keeps the tab chosen
key={flow.streamKind == null ? "http" : "stream"}
defaultValue={messagesLabel == null ? undefined : TAB_MESSAGES}
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>
{messagesLabel != null && (
<TabContent value={TAB_MESSAGES}>
<FlowMessages 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">
-109
View File
@@ -1,109 +0,0 @@
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>;
}
+125
View File
@@ -0,0 +1,125 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow } from "@yaakapp-internal/proxy-lib";
import { DetailsBanner, formatMillis, KeyValueRow, KeyValueRows } from "@yaakapp-internal/ui";
import { format } from "date-fns";
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
import { utcTimestamp } from "../lib/flowViews";
import { folderOf } from "../lib/search";
import { AppIcon } from "./AppIcon";
/**
* Where a flow came from and how it travelled: in a proxy you didn't send the request, so the
* connection on each leg, the app and its process, timing, sizes and the flow's record are part
* of the answer.
*/
export function FlowInfo({ flow }: { flow: Flow }) {
const folder = folderOf(flow);
const clientPort = portOf(flow.clientAddr);
return (
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
<Section title="Connection">
{[
row("remote", "Remote Address", flow.remoteAddr, { copy: true }),
row("client_version", "Client Version", flow.clientHttpVersion),
row("server_version", "Server Version", flow.upstreamHttpVersion),
row("tls", "TLS", flow.upstreamTlsVersion),
row("cipher", "Cipher", flow.upstreamTlsCipher),
row("client_port", "Client Port", clientPort),
]}
</Section>
<Section title="App">
{[
row(
"app",
"Name",
flow.clientName == null ? null : (
<span className="flex items-center gap-1.5">
{flow.clientPath != null && <AppIcon path={flow.clientPath} className="size-4" />}
{flow.clientName}
{folder != null && <span className="text-text-subtlest">{folder}</span>}
</span>
),
),
flow.clientBundleId != null
? row("bundle", "Bundle ID", flow.clientBundleId, { copy: true })
: row("path", "Path", flow.clientPath, { copy: true }),
row("pid", "PID", flow.clientPid == null ? null : `${flow.clientPid}`),
row("cwd", "Directory", flow.clientCwd, { copy: true }),
]}
</Section>
<Section title="Timing">
{[
row(
"started",
"Started",
format(utcTimestamp(flow.startedAt), "MMM d, yyyy, h:mm:ss.SSS a"),
),
row("headers", "Headers", ms(flow.elapsedHeadersMs)),
row("total", "Total", ms(flow.elapsedMs)),
]}
</Section>
<Section title="Size">
{[
row("request_size", "Request", formatSize(flow.requestSize)),
row("response_size", "Response", formatSize(flow.responseSize)),
]}
</Section>
<Section title="Record">
{[
row("number", "Number", `#${flow.number}`, { copy: true }),
row("id", "ID", flow.id, { copy: true }),
row("saved", "Saved As", flow.savedRequestId, { copy: true }),
]}
</Section>
</div>
);
}
/** A row as `KeyValueRows` takes it */
type Row = ReactElement<HTMLAttributes<HTMLTableColElement>>;
function Section({ title, children }: { title: string; children: (Row | null)[] }) {
const rows = children.filter((r): r is Row => r != null);
if (rows.length === 0) return null;
return (
<DetailsBanner defaultOpen summary={<h2>{title}</h2>}>
<KeyValueRows>{rows}</KeyValueRows>
</DetailsBanner>
);
}
/** A row, or nothing when there's no value for it */
function row(
key: string,
label: string,
value: ReactNode | null | undefined,
opts: { copy?: boolean } = {},
): Row | null {
if (value == null || value === "") return null;
return (
<KeyValueRow
key={key}
labelColor="secondary"
label={label}
enableCopy={opts.copy}
copy={copyText}
>
{value}
</KeyValueRow>
);
}
function ms(value: number | null): string | null {
return value == null ? null : formatMillis(value);
}
/** The port of `127.0.0.1:54321` or `[::1]:54321` */
export function portOf(addr: string | null | undefined): string | null {
const port = addr?.match(/:(\d+)$/)?.[1];
return port ?? null;
}
function copyText(text: string) {
navigator.clipboard.writeText(text).catch(console.error);
}
+1 -1
View File
@@ -329,7 +329,7 @@ export function FlowList({ style, className }: Props) {
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",
"bg-surface border border-border shadow-lg text-text text-xs tabular-nums",
"hover:bg-surface-highlight",
)}
>
@@ -107,11 +107,8 @@ export const FlowListHeader = memo(function FlowListHeader({
<button
type="button"
onClick={() => onSort(c.id)}
title={
sort?.column === c.id
? "Sorted over the loaded flows. Sort by Start Time in this menu goes back."
: `Sort by ${c.label}`
}
// No tooltip: the header sits under the search field, and a tooltip here covered it
aria-label={`Sort by ${c.label}`}
className={classNames(
"flex items-center gap-0.5 min-w-0 w-full h-full whitespace-nowrap hover:text-text-subtle",
c.align === "right" && "justify-end",
@@ -0,0 +1,61 @@
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, test, vi } from "vite-plus/test";
import type { FlowBodyData } from "../lib/flowViews";
import { FlowRequestBody } from "./FlowRequestBody";
// The package's index reaches for the window as it loads; these three are all this renders.
vi.mock("@yaakapp-internal/ui", () => ({
Banner: ({ children, color }: { children: ReactNode; color?: string }) => (
<div data-banner={color}>{children}</div>
),
EmptyStateText: ({ children }: { children: ReactNode }) => <div>{children}</div>,
LoadingIcon: () => <span>LOADING</span>,
}));
const data = (truncated = false): FlowBodyData => ({
bytes: new TextEncoder().encode('{"a":1}'),
truncated,
storedSize: 7,
});
function render(
body: Parameters<typeof FlowRequestBody>[0]["body"],
binaryViewer: string | null = null,
) {
return renderToStaticMarkup(
<FlowRequestBody
body={body}
editor={<div>EDITOR</div>}
binaryViewer={binaryViewer == null ? null : <div>{binaryViewer}</div>}
requestSize={2048}
/>,
);
}
describe("FlowRequestBody", () => {
test("a body that failed to load says why, not that there is none", () => {
const html = render({ data: undefined, isLoading: false, error: new Error("Key unavailable") });
expect(html).toContain('data-banner="danger"');
expect(html).toContain("Key unavailable");
expect(html).not.toContain("EDITOR");
});
test("shows it is loading while the body is read", () => {
const html = render({ data: undefined, isLoading: true, error: null });
expect(html).not.toContain("EDITOR");
expect(html).toContain("LOADING");
});
test("the editor once loaded, the proxy's viewer for a binary body", () => {
expect(render({ data: data(), isLoading: false, error: null })).toContain("EDITOR");
expect(render({ data: data(), isLoading: false, error: null }, "HEXDUMP")).toContain("HEXDUMP");
});
test("a cut body says how much was kept", () => {
const html = render({ data: data(true), isLoading: false, error: null });
expect(html).toContain("EDITOR");
expect(html).toContain('data-banner="notice"');
expect(html).toContain("kept");
});
});
@@ -0,0 +1,51 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import { Banner, EmptyStateText, LoadingIcon } from "@yaakapp-internal/ui";
import type { HttpBodyResult } from "@yaakapp-internal/ui/httpTypes";
import type { ReactNode } from "react";
import type { FlowBodyData } from "../lib/flowViews";
interface Props {
/** The request body as the engine's body RPC returned it */
body: HttpBodyResult<FlowBodyData | null>;
/** The shared editor for the body's type */
editor: ReactNode;
/** The proxy's own viewer, for a body that isn't text; null for one that is */
binaryViewer: ReactNode | null;
/** Bytes of the request body on the wire */
requestSize: number;
}
/**
* The request half's Body tab: the body in its editor, or why it isn't there yet. A body that
* failed to load says why, as the response's viewer does, rather than reading as no body.
*/
export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Props) {
if (body.error != null) {
return (
<Banner color="danger" className="text-sm">
{body.error.message}
</Banner>
);
}
if (body.isLoading) {
return (
<EmptyStateText>
<LoadingIcon />
</EmptyStateText>
);
}
if (binaryViewer != null) return binaryViewer;
if (body.data?.truncated) {
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<Banner color="notice" className="text-sm mb-2">
<span title="The app sent all of it">
First {formatSize(body.data.storedSize)} of {formatSize(requestSize)} kept
</span>
</Banner>
<div className="min-h-0">{editor}</div>
</div>
);
}
return editor;
}
@@ -0,0 +1,61 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
import {
EventViewer,
EventViewerRow,
formatMillis,
Icon,
type IconProps,
} from "@yaakapp-internal/ui";
import { useMemo } from "react";
import { type FlowPhase, flowPhases, type PhaseKind } from "../lib/phases";
const ICONS: Record<PhaseKind, { icon: IconProps["icon"]; color: IconProps["color"] }> = {
start: { icon: "send_horizontal", color: "secondary" },
step: { icon: "arrow_right", color: "secondary" },
end: { icon: "check", color: "success" },
error: { icon: "alert_triangle", color: "danger" },
cancelled: { icon: "circle_off", color: "secondary" },
};
/** Yaak's event list with the flow's phases as rows, each saying how long it took */
export function FlowTimeline({ flow }: { flow: Flow }) {
const phases = useMemo(() => flowPhases(flow), [flow]);
return (
<EventViewer<FlowPhase>
events={phases}
getEventKey={(p) => p.key}
splitLayoutStorageKey="proxy_flow_timeline"
enableKeyboardNav={false}
emptyMessage="No timings recorded"
renderRow={({ event, isActive, onClick }) => {
const { icon, color } = ICONS[event.kind];
return (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon icon={icon} color={color} size="sm" />}
content={
<span title={event.detail}>
{event.label}
{event.detail != null && (
<span className="text-text-subtlest">{` ${event.detail}`}</span>
)}
</span>
}
columns="auto minmax(0,1fr) 6rem 6rem"
cells={{
trailing: [
<span key="took" className="tabular-nums">
{event.kind === "start" ? "" : `+${formatMillis(event.tookMs)}`}
</span>,
<span key="at" className="tabular-nums opacity-50">
{formatMillis(event.atMs)}
</span>,
],
}}
/>
);
}}
/>
);
}
+13 -3
View File
@@ -25,12 +25,22 @@ export function LockedState({ lock }: { lock: ProxyLock }) {
) : (
<Icon icon="lock" size="lg" className="text-text-subtle mb-1" />
)}
<div className="text-lg font-semibold">
{encrypting != null ? `Encrypting stored traffic, ${encrypting}` : "Traffic is locked"}
<div className="text-lg font-semibold whitespace-nowrap">
{encrypting != null ? (
<>
Encrypting stored traffic,{" "}
{/* Sized for "100%" and right-aligned, so the centred line never changes width as
the number ticks: a changing width re-rasterizes every glyph at a new
sub-pixel offset, and the line shakes */}
<span className="inline-block w-[4.5ch] text-right tabular-nums">{encrypting}</span>
</>
) : (
"Traffic is locked"
)}
</div>
<div className="text-sm text-text-subtle">
{encrypting != null
? "Traffic captured before this version is being encrypted on disk. It shows here when it's done."
? "Earlier traffic shows here once it's encrypted."
: lockedLine(lock, getOsType())}
</div>
{encrypting == null && (
+19 -11
View File
@@ -7,10 +7,10 @@ import {
Toasts,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import { useState } from "react";
import { useLocalStorage } from "react-use";
import { flowLayoutAtom, settingsRequestedAtom } from "../lib/atoms";
import { flowLayoutAtom } from "../lib/atoms";
import { setupIncomplete } from "../lib/firstRun";
import { lockedAtom, noFlowsAtom, systemProxyEverOnAtom } from "../lib/store";
import { getOsType } from "../lib/tauri";
@@ -20,17 +20,17 @@ import { FlowDetail } from "./FlowDetail";
import { FlowList } from "./FlowList";
import { LockedState } from "./LockedState";
import { ProxyStatus, useProxySetup } from "./ProxyStatus";
import { DecryptListDialog } from "./DecryptListEditor";
import { HelperDialog } from "./HelperDialog";
import { SaveDialog } from "./SaveDialog";
import { SearchBar } from "./SearchBar";
import { SettingsWindow, useOpenSettings } from "./Settings";
import { Sidebar } from "./Sidebar";
import { TakeOverBanner } from "./TakeOverBanner";
export function ProxyLayout() {
const os = getOsType();
const [flowLayout, setFlowLayout] = useAtom(flowLayoutAtom);
const requestSettings = useSetAtom(settingsRequestedAtom);
const openSettings = useOpenSettings();
const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar_width", 250);
const [sidebarHidden, setSidebarHidden] = useLocalStorage("sidebar_hidden", false);
const [floatingSidebarHidden, setFloatingSidebarHidden] = useLocalStorage(
@@ -57,10 +57,16 @@ export function ProxyLayout() {
className="x-theme-appHeader bg-surface"
>
{/* Yaak's silhouette: the sidebar toggle and the state pill where Yaak has the toggle and
the environment, the search where it names the request, then its icons. */}
<div className="grid grid-cols-[minmax(0,1fr)_minmax(10rem,26rem)_minmax(0,1fr)] gap-2 items-center w-full h-full pointer-events-none">
<div className="flex items-center gap-1 pl-1 min-w-0 pointer-events-auto">
the environment, the search where it names the request, then its icons. Only the
controls take the pointer: everything around them, the traffic lights' corner
included, falls through to the header, which drags the window. */}
<div
data-tauri-drag-region
className="grid grid-cols-[minmax(auto,1fr)_minmax(0,26rem)_minmax(auto,1fr)] gap-2 items-center w-full h-full pointer-events-none"
>
<div data-tauri-drag-region className="flex items-center gap-1 pl-1 whitespace-nowrap">
<IconButton
className="pointer-events-auto"
size="sm"
title="Toggle sidebar"
icon={isHidden ? "left_panel_hidden" : "left_panel_visible"}
@@ -75,8 +81,10 @@ export function ProxyLayout() {
/>
<ProxyStatus />
</div>
<div className="pointer-events-auto min-w-0">
<SearchBar />
<div data-tauri-drag-region className="min-w-0">
<div className="pointer-events-auto">
<SearchBar />
</div>
</div>
<div data-tauri-drag-region className="flex items-center justify-end gap-1 pr-1">
<ClearFlowsButton />
@@ -96,7 +104,7 @@ export function ProxyLayout() {
title="Settings"
size="sm"
iconColor="secondary"
onClick={() => requestSettings((n) => n + 1)}
onClick={() => openSettings()}
/>
</div>
</div>
@@ -151,7 +159,7 @@ export function ProxyLayout() {
</SidebarLayout>
<SaveDialog />
<HelperDialog />
<DecryptListDialog />
<SettingsWindow />
<Toasts />
</div>
);
+6 -153
View File
@@ -10,23 +10,17 @@ import {
LoadingIcon,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import { useAtom, useAtomValue } 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 { decryptListDialogOpenAtom } from "./DecryptListEditor";
import { helperRegisteredAtAtom } from "../lib/atoms";
import { encryptingPercent } from "../lib/encrypting";
import { environmentExports } from "../lib/env";
import { proxyStateAtom, refreshProxyState, systemProxyEverOnAtom } from "../lib/store";
import {
helperPollInterval,
helperRow,
recordingLabel,
systemProxySwitch,
} from "../lib/systemProxy";
import { proxyStateAtom, systemProxyEverOnAtom } from "../lib/store";
import { helperPollInterval, recordingLabel, systemProxySwitch } from "../lib/systemProxy";
import { onWindowFocus } from "../lib/tauri";
import { useShowToast } from "../lib/toast";
@@ -34,7 +28,7 @@ import { useShowToast } from "../lib/toast";
* The header's state pill and its menu of four rows: the state, Pause or Resume Recording,
* Override system proxy, and the certificate. 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. More… holds the rows that move to Settings until it exists.
* menu beside the pill until it is. Everything set once and left alone lives in Settings.
*/
export function ProxyStatus() {
const dropdownRef = useRef<DropdownRef>(null);
@@ -42,11 +36,6 @@ export function ProxyStatus() {
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 setDecryptListOpen = useSetAtom(decryptListDialogOpenAtom);
const { caStatus, trust, systemProxy, setSystemProxy } = setup;
const systemSwitch = useSystemProxySwitch(setup);
@@ -113,72 +102,6 @@ export function ProxyStatus() {
systemProxy.data.helper !== "enabled" &&
systemProxy.data.helper !== "unavailable";
// The rows that move to Settings, reachable here until it exists.
const moreItems: DropdownItem[] = [
{
type: "content",
hidden: systemProxy.data?.supported !== true,
label: (
<KeepMenuOpen className="block px-1.5 py-1.5">
<Checkbox
title={<span className="text-sm">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: (
<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: "Decrypt List…",
icon: "lock_open",
onSelect: () => setDecryptListOpen(true),
},
{
label: "Copy Environment for a Terminal",
icon: "square_terminal",
onSelect: copyEnvironment,
},
];
const items: DropdownItem[] = [
{ type: "content", label: stateRow },
{
@@ -204,7 +127,7 @@ export function ProxyStatus() {
</span>
{helperNotice && (
<span className="block text-notice">
macOS asks for your password each time until the helper is approved, under More.
macOS asks for your password each time until the helper is approved in Settings.
</span>
)}
</>
@@ -262,8 +185,6 @@ export function ProxyStatus() {
/>
),
},
{ type: "separator" },
{ label: "More…", icon: "settings", submenu: moreItems },
];
return (
@@ -371,74 +292,6 @@ export function useSystemProxySwitch(setup: ReturnType<typeof useProxySetup>) {
}
/** 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", {});
+12 -29
View File
@@ -7,9 +7,7 @@ import {
searchTermsAtom,
searchTextAtom,
searchTextVersionAtom,
searchWindowAtom,
setSearchTextAtom,
shownTotalAtom,
} from "../lib/store";
/** How long typing settles before the search runs */
@@ -17,8 +15,8 @@ const SEARCH_DEBOUNCE_MS = 200;
/**
* The header's search over every flow in the database, by URL, method, status and content type,
* and the `@app`, `@host` and `@path` terms the sidebar writes. Its placeholder counts the flows;
* a search's matches show inside it. ⌘F focuses it and Escape clears it.
* and the `@app`, `@host` and `@path` terms the sidebar writes. Its placeholder counts the flows.
* ⌘F focuses it and Escape clears it.
*/
export const SearchBar = memo(function SearchBar() {
const [text, setText] = useAtom(searchTextAtom);
@@ -75,26 +73,24 @@ export const SearchBar = memo(function SearchBar() {
}}
leftSlot={
<>
<Icon icon="search" size="sm" className="ml-2 mr-1 self-center text-text-subtlest" />
<Icon icon="search" size="sm" className="ml-2 mr-2 self-center text-text-subtlest" />
<PlaceholderCount inputRef={inputRef} />
</>
}
rightSlot={
text === "" ? (
<kbd className="self-center mr-2 px-1 rounded-sm border border-border-subtle font-sans text-2xs text-text-subtlest">
<kbd className="self-center ml-2 mr-2 px-1 rounded-sm border border-border-subtle font-sans text-2xs text-text-subtlest">
⌘F
</kbd>
) : (
<div className="flex items-center gap-1 self-center">
<MatchCount />
<IconButton
size="xs"
icon="x"
title="Clear search"
iconColor="secondary"
onClick={clear}
/>
</div>
<IconButton
size="xs"
icon="x"
title="Clear search"
iconColor="secondary"
className="self-center ml-2 mr-0.5"
onClick={clear}
/>
)
}
/>
@@ -118,16 +114,3 @@ function placeholderFor(total: number): string {
if (total === 0) return "Search flows";
return `Search ${total.toLocaleString()} ${total === 1 ? "flow" : "flows"}`;
}
/** A search's matches of all traffic, "12 of 4,102", inside the field before its x */
function MatchCount() {
const matches = useAtomValue(shownTotalAtom);
const total = useAtomValue(allTrafficTotalAtom);
const search = useAtomValue(searchWindowAtom);
if (search == null || search.loading) return null;
return (
<span className="font-mono text-2xs text-text-subtlest tabular-nums whitespace-nowrap">
{matches.toLocaleString()} of {total.toLocaleString()}
</span>
);
}
+181
View File
@@ -0,0 +1,181 @@
import { useQuery } from "@tanstack/react-query";
import { getVersion } from "@tauri-apps/api/app";
import { appDataDir, appLogDir } from "@tauri-apps/api/path";
import {
Dialog,
EmptyStateText,
SETTINGS_TAB_GENERAL,
SETTINGS_TAB_INTERFACE,
SETTINGS_TAB_LICENSE,
SETTINGS_TAB_THEME,
type SettingsCallbacks,
SettingsDialog,
SettingsGeneral,
SettingsInterface,
SettingsTheme,
} from "@yaakapp-internal/ui";
import { atom, useAtom, useSetAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { settingsValuesAtom } from "../lib/interfaceSettings";
import { openLink, revealPath } from "../lib/opener";
import { proxySettingsView } from "../lib/settings";
import { getOsType } from "../lib/tauri";
import { useShowToast } from "../lib/toast";
import { SettingsProxy } from "./SettingsProxy";
export const SETTINGS_TAB_PROXY = "proxy";
/** The settings window is open, on a tab, or closed */
export const settingsOpenAtom = atom<string | null>(null);
/**
* Yaak's settings, shared: its General, Theme, Interface and License tabs, plus the proxy's own
* Proxy tab. The header's gear and `⌘,` open it.
*/
export function SettingsWindow() {
const [tab, setTab] = useAtom(settingsOpenAtom);
useSettingsHotkey();
return (
<Dialog
open={tab != null}
onClose={() => setTab(null)}
size="lg"
noPadding
noScroll
className="h-[min(44rem,90vh)]"
>
{tab != null && <SettingsContent defaultTab={tab} />}
</Dialog>
);
}
/** The settings themselves, as the window holds them */
export function SettingsContent({ defaultTab }: { defaultTab: string }) {
const [values, change] = useAtom(settingsValuesAtom);
const app = useAppInfo();
const systemAppearance = useSystemAppearance();
const view = useMemo(
() => proxySettingsView(values, app, systemAppearance),
[values, app, systemAppearance],
);
const showToast = useShowToast();
const callbacks = useMemo<SettingsCallbacks>(() => {
const fail = (message: string) => showToast({ message, color: "danger" });
return {
onChange: (patch) => change(patch),
// The proxy draws its own title bar always; the row isn't shown
onApplyNativeTitlebar: (useNativeTitlebar) => change({ useNativeTitlebar }),
onCheckForUpdates: () => undefined,
onActivateLicense: async () => undefined,
onDeactivateLicense: () => undefined,
onOpenUrl: (url) => void openLink(url, fail),
onRevealPath: (path) => void revealPath(path, fail),
pricingUrl: (intent) => `https://yaak.app/pricing?intent=${encodeURIComponent(intent)}`,
};
}, [change, showToast]);
const props = { view, callbacks };
return (
<SettingsDialog
titlebar={null}
defaultTab={defaultTab}
tabs={[
{
value: SETTINGS_TAB_GENERAL,
label: "General",
icon: "settings",
content: <SettingsGeneral {...props} />,
},
{
value: SETTINGS_TAB_THEME,
label: "Theme",
icon: "palette",
content: <SettingsTheme {...props} />,
},
{
value: SETTINGS_TAB_INTERFACE,
label: "Interface",
icon: "columns_2",
content: <SettingsInterface {...props} />,
},
{
value: SETTINGS_TAB_PROXY,
label: "Proxy",
icon: "wifi",
content: <SettingsProxy />,
},
{
value: SETTINGS_TAB_LICENSE,
label: "License",
icon: "key_round",
content: <EmptyStateText>Coming soon</EmptyStateText>,
},
]}
/>
);
}
/** `⌘,`, or `Ctrl+,` off macOS, opens settings, as in Yaak */
function useSettingsHotkey() {
const setOpen = useSetAtom(settingsOpenAtom);
useEffect(() => {
const mac = getOsType() === "macos";
function onKeyDown(e: KeyboardEvent) {
if (e.key !== "," || e.shiftKey || e.altKey) return;
if (mac ? !e.metaKey || e.ctrlKey : !e.ctrlKey || e.metaKey) return;
e.preventDefault();
setOpen((tab) => tab ?? SETTINGS_TAB_GENERAL);
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [setOpen]);
}
function useAppInfo() {
const os = getOsType();
const { data } = useQuery({
queryKey: ["settings_app_info"],
queryFn: async () => {
const [version, dataDir, logDir] = await Promise.all([
getVersion(),
appDataDir(),
appLogDir(),
]);
return { version, dataDir, logDir };
},
retry: false,
});
return useMemo(
() => ({
name: "Yaak Proxy",
version: data?.version ?? "",
dataDir: data?.dataDir ?? "",
logDir: data?.logDir ?? "",
revealPathLabel:
os === "macos"
? "Reveal in Finder"
: os === "windows"
? "Show in Explorer"
: "Show in Folder",
}),
[data, os],
);
}
function useSystemAppearance(): "light" | "dark" {
const query = "(prefers-color-scheme: dark)";
const [dark, setDark] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const media = window.matchMedia(query);
const onChange = () => setDark(media.matches);
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}, []);
return dark ? "dark" : "light";
}
/** Opens settings on a tab */
export function useOpenSettings() {
const setOpen = useSetAtom(settingsOpenAtom);
return (tab: string = SETTINGS_TAB_GENERAL) => setOpen(tab);
}
@@ -0,0 +1,366 @@
import { useQueryClient } from "@tanstack/react-query";
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { HttpVersion, SystemProxyStatus } from "@yaakapp-internal/proxy-lib";
import {
Button,
Dialog,
DialogFooter,
Heading,
Icon,
RadioDropdown,
SettingRow,
SettingRowBoolean,
PlainInput,
SettingRowSelect,
SettingSelectControl,
SettingSnippet,
SettingsList,
SettingsSection,
VStack,
} from "@yaakapp-internal/ui";
import { useAtomValue, useSetAtom, useStore } from "jotai";
import { useState } from "react";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { useRpcQuery } from "../hooks/useRpcQuery";
import { helperDialogOpenAtom } from "../lib/atoms";
import {
defaultShell,
environmentBlock,
environmentVars,
TERMINAL_SHELLS,
type TerminalShell,
} from "../lib/env";
import {
CAPTURE_LIMIT_OPTIONS,
KEEP_TRAFFIC_OPTIONS,
sizeFromOptionValue,
sizeOptionsFor,
sizeOptionValue,
} from "../lib/settings";
import { helperRow } from "../lib/systemProxy";
import { proxyStateAtom, refreshProxyState } from "../lib/store";
import { getOsType } from "../lib/tauri";
import { useShowToast } from "../lib/toast";
import { DecryptListEditor } from "./DecryptListEditor";
/**
* The proxy's own settings tab: how it listens, what it decrypts, the system proxy and its
* helper, how much traffic it keeps, and the environment for one terminal. Everything set once
* and left alone, which is what left the state pill's menu.
*/
export function SettingsProxy() {
const proxyState = useAtomValue(proxyStateAtom);
const store = useStore();
const refresh = { onSettled: () => void refreshProxyState(store) };
const setHttpVersion = useRpcMutation("set_http_version", refresh);
const setCaptureLimit = useRpcMutation("set_capture_body_limit", refresh);
const setAtLaunch = useRpcMutation("set_system_proxy_at_launch", refresh);
const systemProxy = useRpcQuery("system_proxy_status", {});
// Another process owning the engine wouldn't see changes until it restarts
const elsewhere = proxyState?.otherOwner != null;
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Proxy</Heading>
<p className="text-text-subtle">Listening, decryption, storage and terminals</p>
</div>
<SettingsList className="space-y-8">
<SettingsSection title="Listening">
<PortRow disabled={elsewhere} />
<SettingRowSelect<HttpVersion>
name="httpVersion"
title="HTTP version"
description="For new connections"
value={proxyState?.httpVersion ?? "auto"}
disabled={elsewhere}
onChange={(httpVersion) => setHttpVersion.mutate({ httpVersion })}
options={[
{ label: "Auto", value: "auto" },
{ label: "HTTP/1.1 only", value: "http1Only" },
]}
/>
<SettingRowSelect
name="captureBodyLimit"
title="Body limit"
description="Stored per body"
value={sizeOptionValue(proxyState?.captureBodyLimit ?? null)}
disabled={elsewhere}
onChange={(v) => {
const bytes = sizeFromOptionValue(v);
if (bytes != null) setCaptureLimit.mutate({ bytes });
}}
options={sizeOptionsFor(CAPTURE_LIMIT_OPTIONS, proxyState?.captureBodyLimit ?? null)
.filter((o) => o.bytes != null)
.map(({ label, value }) => ({ label, value }))}
/>
</SettingsSection>
<SettingsSection title="Decrypt">
<DecryptListEditor className="py-4" />
</SettingsSection>
{systemProxy.data?.supported === true && (
<SettingsSection title="System proxy">
<SettingRowBoolean
title="Override at launch"
checked={proxyState?.systemProxyAtLaunch ?? true}
disabled={setAtLaunch.isPending}
onChange={(enabled) => setAtLaunch.mutate({ enabled })}
/>
<HelperRow status={systemProxy.data} />
</SettingsSection>
)}
<SettingsSection title="Storage">
<KeepTrafficRow />
<ResetRow />
</SettingsSection>
<SettingsSection title="Terminal">
<TerminalRow />
</SettingsSection>
</SettingsList>
</VStack>
);
}
/** A port from 1 to 65535, or null */
function parsePort(text: string): number | null {
if (!/^\d+$/.test(text.trim())) return null;
const port = Number(text.trim());
return port > 0 && port < 65536 ? port : null;
}
/**
* The port, saved when the field is left or Enter is pressed rather than on each keystroke:
* every save restarts a running proxy, so typing 8080 must not restart it on 8, 80 and 808.
*/
function PortRow({ disabled }: { disabled: boolean }) {
const proxyState = useAtomValue(proxyStateAtom);
const store = useStore();
const showToast = useShowToast();
const saved = proxyState?.configuredPort ?? 9090;
const [draft, setDraft] = useState<string | null>(null);
// Puts the saved port back in the field after a rejected or failed change
const [resets, setResets] = useState(0);
const reset = () => {
setDraft(null);
setResets((n) => n + 1);
};
const setPort = useRpcMutation("set_port", {
onError: (err) => showToast({ message: err.message, color: "danger" }),
onSettled: () => {
reset();
void refreshProxyState(store);
},
});
const commit = () => {
if (draft == null || setPort.isPending) return;
const port = parsePort(draft);
if (port == null || port === saved) {
reset();
return;
}
setPort.mutate({ port });
};
return (
<SettingRow title="Port" description="Restarts the proxy">
<PlainInput
hideLabel
size="sm"
name="port"
label="Port"
type="number"
containerClassName="w-48!"
defaultValue={`${saved}`}
forceUpdateKey={`${saved}.${resets}`}
disabled={disabled || setPort.isPending}
validate={(v) => parsePort(v) != null}
onChange={setDraft}
onBlur={commit}
onKeyDownCapture={(e) => {
if (e.key === "Enter") commit();
}}
/>
</SettingRow>
);
}
function HelperRow({ status }: { status: SystemProxyStatus }) {
const queryClient = useQueryClient();
const openDialog = useSetAtom(helperDialogOpenAtom);
const unregister = useRpcMutation("helper_unregister", {
onSuccess: (status) =>
queryClient.setQueryData<SystemProxyStatus>(["system_proxy_status", {}], status),
});
const row = helperRow(status.helper, status.backend);
return (
<SettingRow
title="Helper"
description={
unregister.error != null ? (
<span className="text-danger">{unregister.error.message}</span>
) : (
`${capitalize(row.title.replace(/^Helper: /, ""))}. ${row.description}`
)
}
>
{row.action != null && (
<Button
size="xs"
variant="border"
color={row.action === "unregister" ? "default" : "primary"}
isLoading={unregister.isPending}
onClick={() => (row.action === "unregister" ? unregister.mutate({}) : openDialog(true))}
>
{row.action === "install"
? "Install"
: row.action === "approve"
? "Approve"
: "Unregister"}
</Button>
)}
</SettingRow>
);
}
function KeepTrafficRow() {
const proxyState = useAtomValue(proxyStateAtom);
const store = useStore();
const size = useRpcQuery("get_storage_size", {}, { refetchInterval: 10_000 });
const setKeep = useRpcMutation("set_keep_traffic", {
onSettled: () => void refreshProxyState(store),
});
const keep = proxyState?.keepTrafficBytes ?? null;
return (
<SettingRow title="Keep traffic" description="Oldest flows dropped past the limit">
<div className="flex items-center gap-3">
{size.data != null && (
<span className="text-sm text-text-subtle tabular-nums whitespace-nowrap">
{formatSize(size.data.bytes)} used
</span>
)}
<SettingSelectControl
name="keepTraffic"
label="Keep traffic"
value={sizeOptionValue(keep)}
disabled={proxyState?.otherOwner != null}
onChange={(v) => setKeep.mutate({ bytes: sizeFromOptionValue(v) })}
options={sizeOptionsFor(KEEP_TRAFFIC_OPTIONS, keep).map(({ label, value }) => ({
label,
value,
}))}
/>
</div>
</SettingRow>
);
}
function ResetRow() {
const [confirming, setConfirming] = useState(false);
const store = useStore();
const queryClient = useQueryClient();
const showToast = useShowToast();
const reset = useRpcMutation("reset_captured_data", {
onSuccess: () => {
setConfirming(false);
void queryClient.invalidateQueries();
showToast({ message: "Captured data reset" });
},
onSettled: () => void refreshProxyState(store),
});
return (
<SettingRow title="Reset captured data" description="Deletes all flows and their keys">
<Button size="xs" variant="border" color="danger" onClick={() => setConfirming(true)}>
Reset…
</Button>
<Dialog
open={confirming}
onClose={() => setConfirming(false)}
size="sm"
title="Reset captured data?"
description="Every captured flow and the keys that encrypt them are deleted."
>
{reset.error != null && <p className="text-danger text-sm">{reset.error.message}</p>}
<DialogFooter
actions={[
{ label: "Cancel", variant: "border", onClick: () => setConfirming(false) },
{
label: "Reset",
color: "danger",
isLoading: reset.isPending,
onClick: () => reset.mutate({}),
},
]}
/>
</Dialog>
</SettingRow>
);
}
function TerminalRow() {
const proxyState = useAtomValue(proxyStateAtom);
const caStatus = useRpcQuery("ca_status", {});
const showToast = useShowToast();
const os = getOsType();
const [shell, setShell] = useState<TerminalShell>(() => defaultShell(os));
const openTerminal = useRpcMutation("open_terminal", {
onError: (err) => showToast({ message: err.message, color: "danger" }),
});
const port =
proxyState?.port ?? proxyState?.otherOwner?.proxyPort ?? proxyState?.configuredPort ?? 9090;
const caPath = caStatus.data?.certPath;
const shellLabel = TERMINAL_SHELLS.find((s) => s.value === shell)?.label ?? shell;
return (
<div className="py-4">
<div className="flex items-center gap-2">
<span className="text-text">Environment</span>
<RadioDropdown
value={shell}
onChange={(v) => setShell(v as TerminalShell)}
items={TERMINAL_SHELLS.map((s) => ({ label: s.label, value: s.value }))}
>
<Button
size="2xs"
variant="border"
title="Shell"
rightSlot={<Icon icon="chevron_down" size="xs" className="ml-1" />}
>
{shellLabel}
</Button>
</RadioDropdown>
<Button
size="2xs"
variant="border"
className="ml-auto"
title={
os === "macos"
? "A new Terminal window with these set"
: "Not available on this platform yet"
}
disabled={caPath == null || os !== "macos"}
isLoading={openTerminal.isPending}
onClick={() =>
caPath != null && openTerminal.mutate({ vars: environmentVars(port, caPath) })
}
>
Open Terminal
</Button>
</div>
<p className="mt-1 text-sm text-text-subtle">Proxies one terminal instead of the system</p>
{caPath != null && (
<SettingSnippet
className="mt-3"
language="text"
snippet={environmentBlock(shell, port, caPath)}
/>
)}
</div>
);
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
+1
View File
@@ -63,6 +63,7 @@ function ItemInner({ item }: { item: SidebarItem }) {
<div className="flex items-center gap-2 w-full min-w-0">
{item.kind === "all" && <Icon icon="layers" size="sm" className="text-text-subtle" />}
{item.kind === "tunnelled" && <Icon icon="lock" size="sm" className="text-text-subtle" />}
{item.kind === "folder" && <Icon icon="folder" size="sm" className="text-text-subtle" />}
{item.appPath != null && <AppIcon path={item.appPath} />}
<span className="truncate">{item.label}</span>
{item.sublabel != null && (
+6 -3
View File
@@ -1,3 +1,6 @@
// Yaak's default interface size, so the two apps read as one family. Later from the shared
// Interface settings.
document.documentElement.style.fontSize = "15px";
import { applyFontSizes } from "./lib/interfaceSettings";
import { readSettingsValues, SETTINGS_VALUES_KEY } from "./lib/settings";
// Yaak's default sizes unless changed in Settings' Interface tab, so the two apps read as one
// family.
applyFontSizes(readSettingsValues(localStorage.getItem(SETTINGS_VALUES_KEY)));
-212
View File
@@ -1,212 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yaak Proxy, shared settings harness</title>
<style>
html,
body,
#root {
height: 100%;
margin: 0;
}
</style>
</head>
<body class="text-base">
<!--
Dev only, served by `vp dev` at /harness-settings.html?theme=dark&tab=proxy; the build's only
entry is index.html. Renders the settings dialog shared from packages/ui with a stub settings
view and one tab of the proxy's own, passed in as a slot, until the proxy has a real one.
-->
<div id="root"></div>
<script>
// Outside the app there's no Tauri; the platform package still looks for its window
window.__TAURI_INTERNALS__ = {
metadata: {
currentWindow: { label: "harness" },
currentWebview: { label: "harness", windowLabel: "harness" },
},
invoke: () => Promise.reject(new Error("Not running in the app")),
transformCallback: () => 0,
};
// What the OS plugin hands the page at launch, which the settings' selects read
window.__TAURI_OS_PLUGIN_INTERNALS__ = { os_type: "macos", platform: "macos" };
</script>
<script type="module" src="/font-size.ts"></script>
<script type="module">
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { applyThemeToDocument, defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
import {
Heading,
SETTINGS_TAB_GENERAL,
SETTINGS_TAB_INTERFACE,
SETTINGS_TAB_LICENSE,
SETTINGS_TAB_THEME,
SettingCommand,
SettingRowBoolean,
SettingRowNumber,
SettingRowSelect,
SettingsDialog,
SettingsGeneral,
SettingsInterface,
SettingsLicense,
SettingsList,
SettingsSection,
SettingsTheme,
VStack,
} from "@yaakapp-internal/ui";
import { createElement as h, StrictMode, useState } from "react";
import { createRoot } from "react-dom/client";
import "/main.css";
const params = new URLSearchParams(location.search);
const dark = params.get("theme") !== "light";
applyThemeToDocument(dark ? defaultDarkTheme : defaultLightTheme);
const themeOption = ({ id, label, dark }) => ({ id, label, dark });
const initialValues = {
appearance: dark ? "dark" : "light",
themeLight: defaultLightTheme.id,
themeDark: defaultDarkTheme.id,
interfaceFont: null,
interfaceFontSize: 15,
editorFont: null,
editorFontSize: 13,
editorKeymap: "default",
editorSoftWrap: true,
coloredMethods: false,
useNativeTitlebar: false,
hideWindowControls: false,
updateChannel: "stable",
autoupdate: true,
autoDownloadUpdates: true,
checkNotifications: true,
promptFeedback: true,
};
function view(values) {
const active = dark ? defaultDarkTheme : defaultLightTheme;
return {
app: {
name: "Yaak Proxy",
version: "2026.11.0",
dataDir: "~/Library/Application Support/app.yaak.proxy",
logDir: "~/Library/Logs/app.yaak.proxy",
revealPathLabel: "Reveal in Finder",
},
values,
themes: {
themes: [defaultDarkTheme, defaultLightTheme].map(themeOption),
light: themeOption(defaultLightTheme),
dark: themeOption(defaultDarkTheme),
active: themeOption(active),
resolvedAppearance: dark ? "dark" : "light",
},
fonts: { uiFonts: [], editorFonts: [] },
windowControlsSetting: false,
updates: { checking: false },
feedbackPrompts: false,
license: {
pending: false,
status: { status: "trialing", trialEnd: new Date(Date.now() + 12.5 * 864e5).toISOString() },
checkError: null,
activateError: null,
activating: false,
},
};
}
/** A stand-in for the proxy's own tab, to show one passes in beside the shared ones */
function ProxyTab() {
const [port, setPort] = useState(9090);
const [httpVersion, setHttpVersion] = useState("h2");
const [atLaunch, setAtLaunch] = useState(true);
return h(VStack, { space: 1.5, className: "mb-4" }, [
h("div", { key: "head", className: "mb-3" }, [
h(Heading, { key: "h" }, "Proxy"),
h(
"p",
{ key: "p", className: "text-text-subtle" },
"How Yaak Proxy listens, what it keeps, and how it sets the system proxy.",
),
]),
h(SettingsList, { key: "list", className: "space-y-8" }, [
h(SettingsSection, { key: "listening", title: "Listening" }, [
h(SettingRowNumber, {
key: "port",
name: "port",
title: "Port",
description: "Where apps send their traffic. Changing it restarts the proxy.",
value: port,
onChange: setPort,
}),
h(SettingRowSelect, {
key: "http",
name: "httpVersion",
title: "HTTP version",
description: "What apps may speak to the proxy; it applies to new connections.",
value: httpVersion,
onChange: setHttpVersion,
options: [
{ label: "HTTP/2 and HTTP/1.1", value: "h2" },
{ label: "HTTP/1.1 only", value: "h1" },
],
}),
]),
h(SettingsSection, { key: "system", title: "System proxy" }, [
h(SettingRowBoolean, {
key: "launch",
title: "Override system proxy at launch",
description: "Routes every app on this Mac through the proxy as soon as Yaak Proxy opens.",
checked: atLaunch,
onChange: setAtLaunch,
}),
]),
h(SettingsSection, { key: "terminal", title: "Terminal" }, [
h(SettingCommand, {
key: "env",
title: "Environment for one terminal",
description: "For a terminal or one app instead of the whole Mac.",
language: "shell",
snippet: `export HTTP_PROXY=http://127.0.0.1:${port}\nexport HTTPS_PROXY=http://127.0.0.1:${port}`,
}),
]),
]),
]);
}
function Harness() {
const [values, setValues] = useState(initialValues);
const settingsView = view(values);
const callbacks = {
onChange: (patch) => setValues((v) => ({ ...v, ...patch })),
onApplyNativeTitlebar: (useNativeTitlebar) => setValues((v) => ({ ...v, useNativeTitlebar })),
onCheckForUpdates: () => console.log("Check for updates"),
onActivateLicense: async (licenseKey) => console.log("Activate", licenseKey),
onDeactivateLicense: () => console.log("Deactivate"),
onOpenUrl: (url) => window.open(url, "_blank"),
onRevealPath: (path) => console.log("Reveal", path),
pricingUrl: (intent) => `https://yaak.app/pricing?intent=${encodeURIComponent(intent)}`,
};
const props = { view: settingsView, callbacks };
return h(SettingsDialog, {
defaultTab: params.get("tab") ?? SETTINGS_TAB_GENERAL,
titlebar: { osType: "macos", hideWindowControls: false, useNativeTitlebar: false, interfaceScale: 1 },
tabs: [
{ value: SETTINGS_TAB_GENERAL, label: "General", icon: "settings", content: h(SettingsGeneral, props) },
{ value: SETTINGS_TAB_THEME, label: "Theme", icon: "palette", content: h(SettingsTheme, props) },
{ value: SETTINGS_TAB_INTERFACE, label: "Interface", icon: "columns_2", content: h(SettingsInterface, props) },
{ value: "proxy", label: "Proxy", icon: "wifi", content: h(ProxyTab) },
{ value: SETTINGS_TAB_LICENSE, label: "License", icon: "key_round", content: h(SettingsLicense, props) },
],
});
}
createRoot(document.getElementById("root")).render(
h(StrictMode, null, h(QueryClientProvider, { client: new QueryClient() }, h(Harness))),
);
</script>
</body>
</html>
@@ -0,0 +1,57 @@
import { useEffect, useState } from "react";
interface Probe {
x: number;
y: number;
kind: "drag" | "control" | "dead";
}
/**
* Dev only, with `?window&dragcheck`: probes the header across its width and marks what a press
* at each point lands on. Green drags the window (the element hit carries
* `data-tauri-drag-region`, which Tauri reads off the target alone), blue is a control, and red
* is dead space that neither drags nor does anything.
*/
export function DragRegionCheck() {
const [probes, setProbes] = useState<Probe[]>([]);
useEffect(() => {
const timer = setTimeout(() => {
const header = document.querySelector<HTMLElement>(".x-theme-appHeader");
if (header == null) return;
const rect = header.getBoundingClientRect();
const out: Probe[] = [];
for (const y of [rect.top + 6, rect.top + rect.height / 2]) {
for (let x = rect.left + 4; x < rect.right; x += 10) {
const el = document.elementFromPoint(x, y);
const kind =
el == null
? "dead"
: el.hasAttribute("data-tauri-drag-region")
? "drag"
: el.closest("button, input, [role=button], .cm-editor, [role=combobox]") != null
? "control"
: "dead";
out.push({ x, y, kind });
}
}
setProbes(out);
}, 800);
return () => clearTimeout(timer);
}, []);
return (
<div className="pointer-events-none fixed inset-0 z-50">
{probes.map((p) => (
<span
key={`${p.x}:${p.y}`}
className="absolute size-1.5 -ml-[3px] -mt-[3px] rounded-full"
style={{
left: p.x,
top: p.y,
background:
p.kind === "drag" ? "#22c55e" : p.kind === "control" ? "#3b82f6" : "#ef4444",
}}
/>
))}
</div>
);
}
+57 -94
View File
@@ -1,24 +1,17 @@
import {
HttpRequestPaneView,
HttpResponsePaneView,
type HttpBodyResult,
type ResponseViewMode,
} from "@yaakapp-internal/ui";
import { useMemo, useState } from "react";
import { ProxyEditorHost } from "../components/ProxyEditorHost";
import { findHeader } from "../lib/body";
import {
type FlowBodyData,
flowBodyFromRpc,
flowBodyLoader,
flowRequestToView,
flowResponseToView,
} from "../lib/flowViews";
import { useSetAtom } from "jotai";
import { useLayoutEffect, useState } from "react";
import { FlowDetail } from "../components/FlowDetail";
import { FlowList } from "../components/FlowList";
import { selectedFlowAtom } from "../lib/atoms";
import { browserTabMemory, learnedTabKey, flowShape } from "../lib/defaultTab";
import { flowWindowAtom } from "../lib/store";
import { type FlowFixture, fixtures } from "./fixtures";
/**
* Dev only: renders captured flows through the shared request and response panes, read-only,
* side by side, the way the proxy's detail view will. Open `/harness.html?flow=0&theme=dark`.
* Dev only: captured flows in the detail card, over fixtures. Open
* `/harness.html?flow=0&theme=dark`; `&list` puts the flow list beside the card (its halves
* stacked), `&below` puts it above (side by side), and `&tab=info` opens the response half on a
* tab, as though it were chosen by hand for flows of that shape.
*/
export function FlowPanesHarness() {
const params = new URLSearchParams(location.search);
@@ -27,87 +20,57 @@ export function FlowPanesHarness() {
if (fixture == null) return null;
return (
<ProxyEditorHost formatText={formatLocally}>
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] gap-2 p-2 bg-surface">
<div className="flex gap-2 text-sm">
{fixtures.map((f, i) => (
<button
key={f.flow.id}
type="button"
className={i === index ? "text-text" : "text-text-subtle"}
onClick={() => setIndex(i)}
>
{f.label}
</button>
))}
</div>
<FlowPanes key={fixture.flow.id} fixture={fixture} />
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] gap-2 p-2 bg-surface">
<div className="flex gap-2 text-sm">
{fixtures.map((f, i) => (
<button
key={f.flow.id}
type="button"
className={i === index ? "text-text" : "text-text-subtle"}
onClick={() => setIndex(i)}
>
{f.label}
</button>
))}
</div>
</ProxyEditorHost>
);
}
function FlowPanes({ fixture }: { fixture: FlowFixture }) {
const { flow } = fixture;
const [viewMode, setViewMode] = useState<ResponseViewMode>("pretty");
const requestBody = useMemo(() => flowBodyFromRpc(fixture.requestBody), [fixture]);
const responseBody = useMemo(() => flowBodyFromRpc(fixture.responseBody), [fixture]);
const request = useMemo(() => flowRequestToView(flow, requestBody), [flow, requestBody]);
const response = useMemo(() => flowResponseToView(flow, responseBody), [flow, responseBody]);
const requestLoader = useMemo(
() =>
flowBodyLoader({
useBody: () => loaded(requestBody),
contentType: findHeader(flow.requestHeaders, "content-type"),
}),
[requestBody, flow],
);
const responseLoader = useMemo(
() =>
flowBodyLoader({
useBody: () => loaded(responseBody),
contentType: findHeader(flow.responseHeaders, "content-type"),
}),
[responseBody, flow],
);
return (
<div className="grid grid-cols-2 gap-2 min-h-0">
<div className="min-h-0 px-2">
<HttpRequestPaneView
request={request}
fullHeight
forceUpdateKey={flow.updatedAt}
tabsStorageKey="flow_request_tabs"
bodyLoader={requestLoader}
/>
</div>
<HttpResponsePaneView
response={response}
body={responseLoader}
requestBody={requestLoader}
emptyState={<div className="p-3 text-text-subtle">Waiting for a response</div>}
viewMode={viewMode}
onViewModeChange={setViewMode}
tabsStorageKey="flow_response_tabs"
activeTabKey={flow.id}
onCopyAsCurl={() => undefined}
/>
<FixtureCard key={fixture.flow.id} fixture={fixture} />
</div>
);
}
function loaded(data: FlowBodyData): HttpBodyResult<FlowBodyData | null> {
return { data, isLoading: false, error: null };
}
function FixtureCard({ fixture }: { fixture: FlowFixture }) {
const params = new URLSearchParams(location.search);
const setSelected = useSetAtom(selectedFlowAtom);
const setWindow = useSetAtom(flowWindowAtom);
const [ready, setReady] = useState(false);
useLayoutEffect(() => {
const flows = fixtures.map((f, i) => ({ ...f.flow, number: 1401 + i }));
const tab = params.get("tab");
if (tab != null) {
browserTabMemory()?.setItem(learnedTabKey("response", flowShape(fixture.flow)), tab);
}
setWindow({ flows, hasOlder: false, total: flows.length });
setSelected(flows.find((f) => f.id === fixture.flow.id) ?? fixture.flow);
setReady(true);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- once per fixture
}, [fixture]);
if (!ready) return null;
/** The engine formats JSON over RPC, which a page outside the app can't reach */
async function formatLocally(text: string, language: unknown): Promise<string> {
if (language !== "json") return text;
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
if (params.has("list")) {
return (
<div className="h-full min-h-0 grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<FlowList className="min-h-0" />
<FlowDetail sideBySide />
</div>
);
}
if (params.has("below")) {
return (
<div className="h-full min-h-0 grid grid-rows-[minmax(0,0.45fr)_minmax(0,0.55fr)]">
<FlowList className="min-h-0" />
<FlowDetail sideBySide={false} />
</div>
);
}
return <FlowDetail sideBySide={false} />;
}
+11 -3
View File
@@ -3,6 +3,7 @@ import type { GetProxyStateResponse } from "@yaakapp-internal/proxy-lib";
import { useStore } from "jotai";
import { type ReactNode, useEffect, useState } from "react";
import { ProxyLayout } from "../components/ProxyLayout";
import { DragRegionCheck } from "./DragRegionCheck";
import { useHotkeyContextValue } from "../lib/hotkeys";
import { syncFlows, syncProxyState, syncSidebarSelection } from "../lib/store";
@@ -21,6 +22,8 @@ const STATE: GetProxyStateResponse = {
encrypting: null,
locked: { message: LOCKED, keychain: true },
otherOwner: null,
captureBodyLimit: 10 * 1024 * 1024,
keepTrafficBytes: null,
};
/** What the backend answers while the keychain refuses: the state, and every read refused */
@@ -55,8 +58,9 @@ export function installLockedBackend() {
if (cmd.startsWith("plugin:event|")) return 0;
throw new Error(`Not in the harness: ${cmd}`);
};
(window as unknown as { __TAURI_OS_PLUGIN_INTERNALS__: Record<string, string> })
.__TAURI_OS_PLUGIN_INTERNALS__ = {
(
window as unknown as { __TAURI_OS_PLUGIN_INTERNALS__: Record<string, string> }
).__TAURI_OS_PLUGIN_INTERNALS__ = {
os_type: "macos",
platform: "macos",
family: "unix",
@@ -67,7 +71,10 @@ export function installLockedBackend() {
localStorage.setItem("system_proxy_ever_on", "true");
}
/** The whole window, as the app renders it, while captured traffic is locked */
/**
* The whole window, as the app renders it: while captured traffic is locked, or over the
* fixture backend with `?window`
*/
export function LockedHarness() {
const store = useStore();
const [synced, setSynced] = useState(false);
@@ -81,6 +88,7 @@ export function LockedHarness() {
return (
<Hotkeys>
<ProxyLayout />
{new URLSearchParams(location.search).has("dragcheck") && <DragRegionCheck />}
</Hotkeys>
);
}
@@ -0,0 +1,26 @@
import { useStore } from "jotai";
import { useEffect, useState } from "react";
import { SETTINGS_TAB_PROXY, SettingsContent } from "../components/Settings";
import { syncProxyState } from "../lib/store";
/**
* Dev only: the settings window over the fixture backend, at the dialog's size. Open
* `/harness.html?settings&theme=dark`; `&tab=general` opens another tab than Proxy.
*/
export function SettingsHarness() {
const store = useStore();
const [synced, setSynced] = useState(false);
useEffect(() => {
syncProxyState(store);
setSynced(true);
}, [store]);
const tab = new URLSearchParams(location.search).get("tab") ?? SETTINGS_TAB_PROXY;
if (!synced) return null;
return (
<div className="h-full w-full grid place-items-center bg-surface-highlight p-6">
<div className="h-full w-[min(56rem,100%)] bg-surface rounded-lg border border-border-subtle overflow-hidden">
<SettingsContent defaultTab={tab} />
</div>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
import type { GetProxyStateResponse } from "@yaakapp-internal/proxy-lib";
import { fixtures } from "./fixtures";
/**
* Dev only: answers the engine's RPCs the harness pages need, from fixtures, so the card and
* settings render outside the app as they do inside it.
*/
export function installFixtureBackend() {
const internals = (window as unknown as { __TAURI_INTERNALS__: Record<string, unknown> })
.__TAURI_INTERNALS__;
internals.invoke = async (cmd: string, args?: { cmd?: string; payload?: unknown }) => {
if (cmd === "rpc") return answer(args?.cmd ?? "", args?.payload);
if (cmd.startsWith("plugin:event|")) return 0;
throw new Error(`Not in the harness: ${cmd}`);
};
(window as unknown as { __TAURI_OS_PLUGIN_INTERNALS__: Record<string, string> })
.__TAURI_OS_PLUGIN_INTERNALS__ = {
os_type: "macos",
platform: "macos",
family: "unix",
version: "15.0",
eol: "\n",
};
}
export const HARNESS_STATE: GetProxyStateResponse = {
state: "running",
port: 9090,
configuredPort: 9090,
recording: true,
systemProxyAtLaunch: true,
httpVersion: "auto",
captureBodyLimit: 10 * 1024 * 1024,
keepTrafficBytes: 2 * 1024 * 1024 * 1024,
startError: null,
encrypting: null,
locked: null,
otherOwner: null,
};
function answer(cmd: string, payload: unknown): unknown {
const req = (payload ?? {}) as Record<string, unknown>;
switch (cmd) {
case "get_flow_body": {
const fixture = fixtures.find((f) => f.flow.id === req.flowId);
if (fixture == null) throw new Error(`No fixture for ${String(req.flowId)}`);
return req.which === "request" ? fixture.requestBody : fixture.responseBody;
}
case "format_body": {
const text = typeof req.text === "string" ? req.text : "";
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
}
case "app_icon":
return null;
case "get_proxy_state":
return HARNESS_STATE;
case "get_storage_size":
return { bytes: 412 * 1024 * 1024 };
case "ca_status":
return {
trusted: true,
installed: true,
certPath: "/Users/you/Library/Application Support/app.yaak.proxy/ca.pem",
};
case "system_proxy_status":
return {
enabled: true,
port: 9090,
services: [],
backend: "helper",
helper: "enabled",
supported: true,
};
case "get_decrypt_list":
return {
entries: [
{ entry: "host:api.stripe.com", builtIn: false },
{ entry: "app:Google Chrome", builtIn: false },
{ entry: "!host:*.apple.com", builtIn: true },
],
};
case "list_actions":
return { actions: [] };
case "list_flows": {
const flows = fixtures.map((f, i) => ({ ...f.flow, number: 1401 + i })).reverse();
return { flows, total: flows.length, matching: null };
}
case "list_seen":
return { hosts: [], apps: [] };
default:
throw new Error(`Not in the harness: ${cmd}`);
}
}
+28
View File
@@ -51,6 +51,12 @@ const base: Flow = {
grpcStatus: null,
grpcMessage: null,
tunnelled: false,
clientAddr: "127.0.0.1:54321",
elapsedConnectMs: 18,
elapsedTlsMs: 41,
elapsedRequestMs: 43,
upstreamTlsVersion: "TLSv1.3",
upstreamTlsCipher: "TLS13_AES_128_GCM_SHA256",
};
const jsonRequest = JSON.stringify({ amount: 4200, currency: "usd", capture: true });
@@ -147,4 +153,26 @@ export const fixtures: FlowFixture[] = [
requestBody: body(""),
responseBody: body(""),
},
{
label: "Tunnelled",
flow: {
...base,
id: "fl_tunnel",
method: "CONNECT",
url: "bank.example.com:443",
status: null,
elapsedHeadersMs: null,
elapsedMs: 4_210,
clientHttpVersion: "HTTP/1.1",
upstreamHttpVersion: null,
clientName: "Safari",
clientBundleId: "com.apple.Safari",
clientPath: "/Applications/Safari.app",
requestSize: 3_412,
responseSize: 48_210,
tunnelled: true,
},
requestBody: body(""),
responseBody: body(""),
},
];
+6 -1
View File
@@ -3,22 +3,27 @@ import { applyThemeToDocument, defaultDarkTheme, defaultLightTheme } from "@yaak
import { createStore, Provider } from "jotai";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { installFixtureBackend } from "./backend";
import { FlowPanesHarness } from "./FlowPanesHarness";
import { installLockedBackend, LockedHarness } from "./LockedHarness";
import { MessagesHarness } from "./MessagesHarness";
import { SettingsHarness } from "./SettingsHarness";
import "../main.css";
const params = new URLSearchParams(location.search);
const dark = params.get("theme") !== "light";
applyThemeToDocument(dark ? defaultDarkTheme : defaultLightTheme);
if (params.has("locked")) installLockedBackend();
else installFixtureBackend();
createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode>
<QueryClientProvider client={new QueryClient()}>
<Provider store={createStore()}>
{params.has("locked") ? (
{params.has("locked") || params.has("window") ? (
<LockedHarness />
) : params.has("settings") ? (
<SettingsHarness />
) : params.has("stream") ? (
<MessagesHarness />
) : (
@@ -50,6 +50,12 @@ const flowBase: Flow = {
grpcStatus: null,
grpcMessage: null,
tunnelled: false,
clientAddr: null,
elapsedConnectMs: null,
elapsedTlsMs: null,
elapsedRequestMs: null,
upstreamTlsVersion: null,
upstreamTlsCipher: null,
};
/** Seconds past 21:41:12 as a stored timestamp */
-3
View File
@@ -17,9 +17,6 @@ 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);
/** Settings was asked for, by the header's gear; the settings window opens from it once it exists */
export const settingsRequestedAtom = atom(0);
/** The dialog that walks through allowing the helper in System Settings */
export const helperDialogOpenAtom = atom(false);
+3
View File
@@ -21,6 +21,9 @@ export function flowBodyQueryOptions(flow: Flow, which: FlowBody) {
queryFn: async () => viewerBody(await rpc("get_flow_body", { flowId: flow.id, which })),
// Reading a stored body fails the same way every time, so the error shows at once.
retry: false,
// A stream's body changes with each message, each a new key: dropped once unwatched, so a
// long stream left selected doesn't hold a copy per message
...(flow.streamKind == null ? {} : { gcTime: 0 }),
});
}
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, test } from "vite-plus/test";
import {
defaultTabFor,
flowShape,
learnTab,
REQUEST_TAB_BODY,
REQUEST_TAB_HEADERS,
RESPONSE_TAB_BODY,
RESPONSE_TAB_HEADERS,
RESPONSE_TAB_INFO,
RESPONSE_TAB_MESSAGES,
RESPONSE_TAB_TIMELINE,
type TabMemory,
} from "./defaultTab";
type ShapeFlow = Parameters<typeof flowShape>[0];
const ok: ShapeFlow = {
streamKind: null,
status: 200,
error: null,
cancelled: false,
requestSize: 0,
responseSize: 120,
};
function memory(): TabMemory & { items: Map<string, string> } {
const items = new Map<string, string>();
return {
items,
getItem: (k) => items.get(k) ?? null,
setItem: (k, v) => void items.set(k, v),
};
}
const RESPONSE_TABS = [
RESPONSE_TAB_BODY,
RESPONSE_TAB_HEADERS,
"cookies",
RESPONSE_TAB_TIMELINE,
RESPONSE_TAB_INFO,
];
describe("flowShape", () => {
test("names what the flow has", () => {
expect(flowShape(ok)).toBe("response_body");
expect(flowShape({ ...ok, responseSize: 0, requestSize: 20 })).toBe("request_body");
expect(flowShape({ ...ok, responseSize: 0 })).toBe("neither");
expect(flowShape({ ...ok, streamKind: "websocket" })).toBe("stream");
expect(flowShape({ ...ok, status: null, error: "refused" })).toBe("no_response");
expect(flowShape({ ...ok, status: null, cancelled: true })).toBe("no_response");
});
test("a body cut after the headers still has its response", () => {
expect(flowShape({ ...ok, error: "reset mid-body" })).toBe("response_body");
});
});
describe("defaultTabFor", () => {
test("follows the table", () => {
const m = memory();
// A response body
expect(defaultTabFor("request", ok, [], m)).toBe(REQUEST_TAB_HEADERS);
expect(defaultTabFor("request", { ...ok, requestSize: 9 }, [], m)).toBe(REQUEST_TAB_BODY);
expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_BODY);
// A request body, no response body
const post = { ...ok, requestSize: 30, responseSize: 0 };
expect(defaultTabFor("request", post, [], m)).toBe(REQUEST_TAB_BODY);
expect(defaultTabFor("response", post, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_HEADERS);
// Neither
const empty = { ...ok, responseSize: 0 };
expect(defaultTabFor("request", empty, [], m)).toBe(REQUEST_TAB_HEADERS);
expect(defaultTabFor("response", empty, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_HEADERS);
// A stream
const ws = { ...ok, streamKind: "websocket" as const };
expect(defaultTabFor("response", ws, [...RESPONSE_TABS, RESPONSE_TAB_MESSAGES], m)).toBe(
RESPONSE_TAB_MESSAGES,
);
// No response at all
const failed = { ...ok, status: null, error: "refused", responseSize: 0 };
expect(defaultTabFor("response", failed, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_TIMELINE);
expect(defaultTabFor("request", { ...failed, requestSize: 4 }, [], m)).toBe(REQUEST_TAB_BODY);
});
test("a tab chosen by hand becomes the default for the same shape and pane", () => {
const m = memory();
learnTab("response", ok, RESPONSE_TAB_INFO, m);
expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_INFO);
// Not for other shapes, nor the other pane
const post = { ...ok, requestSize: 30, responseSize: 0 };
expect(defaultTabFor("response", post, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_HEADERS);
expect(defaultTabFor("request", ok, ["body", "headers", "info"], m)).toBe(REQUEST_TAB_HEADERS);
});
test("a learned tab the flow doesn't have falls back to the table", () => {
const m = memory();
const ws = { ...ok, streamKind: "websocket" as const };
learnTab("response", ws, RESPONSE_TAB_MESSAGES, m);
learnTab("response", ok, RESPONSE_TAB_MESSAGES, m);
expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_BODY);
});
test("works without storage", () => {
expect(defaultTabFor("response", ok, RESPONSE_TABS, null)).toBe(RESPONSE_TAB_BODY);
});
});
+97
View File
@@ -0,0 +1,97 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
/** The request half's tabs, named as the shared request pane names them */
export const REQUEST_TAB_BODY = "body";
export const REQUEST_TAB_PARAMS = "params";
export const REQUEST_TAB_HEADERS = "headers";
export const REQUEST_TAB_AUTH = "auth";
export const REQUEST_TAB_COOKIES = "cookies";
/** The response half's tabs, named as the shared response pane names them */
export const RESPONSE_TAB_BODY = "body";
export const RESPONSE_TAB_HEADERS = "headers";
export const RESPONSE_TAB_COOKIES = "cookies";
export const RESPONSE_TAB_TIMELINE = "timeline";
export const RESPONSE_TAB_INFO = "info";
export const RESPONSE_TAB_MESSAGES = "messages";
export type FlowPane = "request" | "response";
/** What a flow has, which picks the tab each half opens on */
export type FlowShape = "response_body" | "request_body" | "neither" | "stream" | "no_response";
export function flowShape(
flow: Pick<
Flow,
"streamKind" | "status" | "error" | "cancelled" | "requestSize" | "responseSize"
>,
): FlowShape {
if (flow.streamKind != null) return "stream";
if (flow.status == null && (flow.error != null || flow.cancelled)) return "no_response";
if (flow.responseSize > 0) return "response_body";
if (flow.requestSize > 0) return "request_body";
return "neither";
}
/** The tab a half opens on for a shape, before anything was learned */
export function shapeDefaultTab(pane: FlowPane, shape: FlowShape, hasRequestBody: boolean): string {
if (pane === "request") {
return hasRequestBody ? REQUEST_TAB_BODY : REQUEST_TAB_HEADERS;
}
switch (shape) {
case "response_body":
return RESPONSE_TAB_BODY;
case "stream":
return RESPONSE_TAB_MESSAGES;
case "no_response":
return RESPONSE_TAB_TIMELINE;
case "request_body":
case "neither":
return RESPONSE_TAB_HEADERS;
}
}
/** Where the tab last chosen by hand for a shape is kept */
export function learnedTabKey(pane: FlowPane, shape: FlowShape): string {
return `proxy_learned_tab.${pane}.${shape}`;
}
/** Where learned tabs are read from and written to: local storage in the app */
export interface TabMemory {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
}
/**
* The tab a half opens on for a flow with no tab of its own: the one last chosen by hand for
* flows of its shape, when this flow has that tab, else the shape's default.
*/
export function defaultTabFor(
pane: FlowPane,
flow: Parameters<typeof flowShape>[0],
available: string[],
memory: TabMemory | null,
): string {
const shape = flowShape(flow);
const learned = memory?.getItem(learnedTabKey(pane, shape)) ?? null;
if (learned != null && available.includes(learned)) return learned;
return shapeDefaultTab(pane, shape, flow.requestSize > 0);
}
/** A tab chosen by hand becomes the default for flows of the same shape */
export function learnTab(
pane: FlowPane,
flow: Parameters<typeof flowShape>[0],
tab: string,
memory: TabMemory | null,
) {
memory?.setItem(learnedTabKey(pane, flowShape(flow)), tab);
}
export function browserTabMemory(): TabMemory | null {
try {
return window.localStorage;
} catch {
return null;
}
}
+62 -6
View File
@@ -1,19 +1,75 @@
import { describe, expect, test } from "vite-plus/test";
import { environmentExports } from "./env";
import { defaultShell, environmentBlock, environmentExports } from "./env";
const MAC_PATH = "/Users/me/Library/Application Support/app.yaak.proxy/ca/ca.pem";
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(
expect(environmentExports(9090, MAC_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}'`,
`export SSL_CERT_FILE='${MAC_PATH}'`,
`export NODE_EXTRA_CA_CERTS='${MAC_PATH}'`,
`export REQUESTS_CA_BUNDLE='${MAC_PATH}'`,
].join("\n"),
);
});
});
describe("environmentBlock", () => {
test("bash and zsh quote only what needs it", () => {
const lines = environmentBlock("posix", 9090, "/tmp/it's.pem").split("\n");
expect(lines[0]).toBe("export HTTP_PROXY=http://127.0.0.1:9090");
expect(lines[4]).toBe(`export SSL_CERT_FILE='/tmp/it'\\''s.pem'`);
});
test("fish", () => {
const lines = environmentBlock("fish", 8080, MAC_PATH).split("\n");
expect(lines[0]).toBe("set -gx HTTP_PROXY http://127.0.0.1:8080");
expect(lines[6]).toBe(`set -gx REQUESTS_CA_BUNDLE '${MAC_PATH}'`);
expect(environmentBlock("fish", 1, "/a b/it's\\x").split("\n")[4]).toBe(
`set -gx SSL_CERT_FILE '/a b/it\\'s\\\\x'`,
);
});
test("PowerShell", () => {
const path = "C:\\Users\\O'Neil\\AppData\\Roaming\\app.yaak.proxy\\ca.pem";
const lines = environmentBlock("powershell", 9090, path).split("\n");
expect(lines[1]).toBe("$env:HTTPS_PROXY = 'http://127.0.0.1:9090'");
expect(lines[5]).toBe(
"$env:NODE_EXTRA_CA_CERTS = 'C:\\Users\\O''Neil\\AppData\\Roaming\\app.yaak.proxy\\ca.pem'",
);
});
test("cmd", () => {
const path = "C:\\Program Files\\Yaak\\ca.pem";
const lines = environmentBlock("cmd", 9090, path).split("\n");
expect(lines[0]).toBe(`set "HTTP_PROXY=http://127.0.0.1:9090"`);
expect(lines[4]).toBe(`set "SSL_CERT_FILE=${path}"`);
});
test("names every variable a tool reads", () => {
const block = environmentBlock("posix", 9090, "/ca.pem");
for (const name of [
"HTTP_PROXY",
"HTTPS_PROXY",
"SSL_CERT_FILE",
"NODE_EXTRA_CA_CERTS",
"REQUESTS_CA_BUNDLE",
]) {
expect(block).toContain(`export ${name}=`);
}
});
});
describe("defaultShell", () => {
test("by the platform", () => {
expect(defaultShell("macos")).toBe("posix");
expect(defaultShell("linux")).toBe("posix");
expect(defaultShell("windows")).toBe("powershell");
expect(defaultShell(null)).toBe("posix");
});
});
+67 -18
View File
@@ -1,21 +1,70 @@
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");
/** The shells the terminal environment is written for */
export type TerminalShell = "posix" | "fish" | "powershell" | "cmd";
export const TERMINAL_SHELLS: { value: TerminalShell; label: string }[] = [
{ value: "posix", label: "bash / zsh" },
{ value: "fish", label: "fish" },
{ value: "powershell", label: "PowerShell" },
{ value: "cmd", label: "cmd" },
];
/** The shell a platform's terminal opens by default */
export function defaultShell(osType: string | null | undefined): TerminalShell {
return osType === "windows" ? "powershell" : "posix";
}
/**
* The variables 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 environmentVars(port: number, caPath: string): [string, string][] {
const proxy = `http://127.0.0.1:${port}`;
return [
["HTTP_PROXY", proxy],
["HTTPS_PROXY", proxy],
// curl reads only the lowercase name for plain HTTP.
["http_proxy", proxy],
["https_proxy", proxy],
["SSL_CERT_FILE", caPath],
["NODE_EXTRA_CA_CERTS", caPath],
["REQUESTS_CA_BUNDLE", caPath],
];
}
/** The variables as `shell` sets them, one a line */
export function environmentBlock(shell: TerminalShell, port: number, caPath: string): string {
return environmentVars(port, caPath)
.map(([name, value]) => setLine(shell, name, value))
.join("\n");
}
function setLine(shell: TerminalShell, name: string, value: string): string {
switch (shell) {
case "posix":
return `export ${name}=${SAFE.test(value) ? value : shellQuote(value)}`;
case "fish":
return `set -gx ${name} ${fishQuote(value)}`;
case "powershell":
return `$env:${name} = '${value.replaceAll("'", "''")}'`;
case "cmd":
// The quotes around the pair keep spaces and stop at the closing one, unlike `set A="b"`
return `set "${name}=${value}"`;
}
}
/** Needs no quotes in any POSIX shell or fish */
const SAFE = /^[\w@%+=:,./-]+$/;
/** Fish quotes like the POSIX shells, except a backslash and a quote escape inside single quotes */
function fishQuote(value: string): string {
if (SAFE.test(value)) return value;
return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
}
/** Shell exports for bash and zsh, as the palette and Settings copy them */
export function environmentExports(port: number, caPath: string): string {
return environmentBlock("posix", port, caPath);
}
+6
View File
@@ -59,6 +59,12 @@ const flow: Flow = {
grpcStatus: null,
grpcMessage: null,
tunnelled: false,
clientAddr: null,
elapsedConnectMs: null,
elapsedTlsMs: null,
elapsedRequestMs: null,
upstreamTlsVersion: null,
upstreamTlsCipher: null,
};
function bytes(text: string): FlowBodyData {
+18 -3
View File
@@ -15,7 +15,7 @@ import {
type HttpResponseView,
} from "@yaakapp-internal/ui/httpTypes";
import { useEffect, useState } from "react";
import { base64ToBytes, bytesToText, findHeader, mimeEssence } from "./body";
import { base64ToBytes, bodyAsText, findHeader, mimeEssence } from "./body";
import { statusReason } from "./statusReason";
/** A captured body as the engine's body RPC returns it, decoded */
@@ -32,7 +32,8 @@ export function flowBodyFromRpc(res: GetFlowBodyResponse): FlowBodyData {
/** A captured request as the shared request pane shows it, read-only */
export function flowRequestToView(flow: Flow, body: FlowBodyData | null): HttpRequestView {
const contentType = findHeader(flow.requestHeaders, "content-type");
const text = body == null ? null : bytesToText(body.bytes, body.truncated);
// In its declared charset, as the response viewers read text
const text = body == null ? null : bodyAsText(body.bytes, body.truncated, contentType);
const { bodyType, body: bodyView } = requestBodyView(contentType, body, text);
return {
@@ -84,7 +85,18 @@ export function flowResponseToView(
): HttpResponseView | null {
const ended = flow.elapsedMs != null || flow.error != null || flow.cancelled;
if (flow.status == null && !ended) return null;
return responseView(flow, body, ended);
}
/**
* A flow as the shared cookie lists read it, whether or not a response came: the request
* half's Cookies tab shows what was sent before anything was received.
*/
export function flowCookiesView(flow: Flow): HttpResponseView {
return responseView(flow, null, flow.elapsedMs != null || flow.error != null || flow.cancelled);
}
function responseView(flow: Flow, body: FlowBodyData | null, ended: boolean): HttpResponseView {
const encoding = findHeader(flow.responseHeaders, "content-encoding")?.trim().toLowerCase();
const encoded = encoding != null && encoding !== "" && encoding !== "identity";
@@ -133,7 +145,10 @@ export function flowBodyLoader({
return {
useText: () => {
const body = useBody();
return { ...body, data: mapData(body, (b) => bytesToText(b.bytes, b.truncated)) };
return {
...body,
data: mapData(body, (b) => bodyAsText(b.bytes, b.truncated, contentType)),
};
},
useBytes: () => {
const body = useBody();
+32
View File
@@ -0,0 +1,32 @@
import type { SettingsPatch, SettingsValues } from "@yaakapp-internal/ui";
import { atom } from "jotai";
import { readSettingsValues, SETTINGS_VALUES_KEY } from "./settings";
/** The shared settings tabs' values, kept in local storage */
const valuesAtom = atom<SettingsValues>(
readSettingsValues(localStorage.getItem(SETTINGS_VALUES_KEY)),
);
/** Reads the values, and writes a patch of them through to storage and the window */
export const settingsValuesAtom = atom(
(get) => get(valuesAtom),
(get, set, patch: SettingsPatch) => {
const next = { ...get(valuesAtom), ...patch };
set(valuesAtom, next);
localStorage.setItem(SETTINGS_VALUES_KEY, JSON.stringify(next));
applyFontSizes(next);
if (patch.appearance != null) {
// Loaded on demand: the theme module shows the window as it loads
import("./theme").then((m) => m.setChosenAppearance(next.appearance)).catch(console.error);
}
},
);
/** The interface and editor sizes, the editor's through the variable the editors read */
export function applyFontSizes(
values: Pick<SettingsValues, "interfaceFontSize" | "editorFontSize">,
) {
const root = document.documentElement;
root.style.fontSize = `${values.interfaceFontSize}px`;
root.style.setProperty("--editor-font-size", `${values.editorFontSize}px`);
}
+42
View File
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, test, vi } from "vite-plus/test";
const opener = vi.hoisted(() => ({
openUrl: vi.fn<(url: string) => Promise<void>>(),
revealItemInDir: vi.fn<(path: string) => Promise<void>>(),
}));
vi.mock("@tauri-apps/plugin-opener", () => opener);
const { openLink, revealPath } = await import("./opener");
describe("opener", () => {
beforeEach(() => {
opener.openUrl.mockReset();
opener.revealItemInDir.mockReset();
});
test("a folder that can't be shown says so", async () => {
opener.revealItemInDir.mockRejectedValue("not allowed: opener.reveal_item_in_dir");
const report = vi.fn();
await revealPath("/data/app.yaak.proxy", report);
expect(opener.revealItemInDir).toHaveBeenCalledWith("/data/app.yaak.proxy");
expect(report).toHaveBeenCalledWith(
"Failed to show /data/app.yaak.proxy: not allowed: opener.reveal_item_in_dir",
);
});
test("nothing to report when it works", async () => {
opener.revealItemInDir.mockResolvedValue();
opener.openUrl.mockResolvedValue();
const report = vi.fn();
await revealPath("/logs", report);
await openLink("https://yaak.app", report);
expect(report).not.toHaveBeenCalled();
});
test("a link that can't be opened says so", async () => {
opener.openUrl.mockRejectedValue(new Error("denied"));
const report = vi.fn();
await openLink("https://yaak.app", report);
expect(report).toHaveBeenCalledWith("Failed to open https://yaak.app: Error: denied");
});
});
+22
View File
@@ -0,0 +1,22 @@
import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
/** Says what failed, as a toast, rather than leaving a button that does nothing */
export type ReportFailure = (message: string) => void;
/** Shows a file or folder in the platform's file manager */
export async function revealPath(path: string, report: ReportFailure): Promise<void> {
try {
await revealItemInDir(path);
} catch (err) {
report(`Failed to show ${path}: ${String(err)}`);
}
}
/** Opens a link in the default browser */
export async function openLink(url: string, report: ReportFailure): Promise<void> {
try {
await openUrl(url);
} catch (err) {
report(`Failed to open ${url}: ${String(err)}`);
}
}
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, test } from "vite-plus/test";
import { flowPhases, type PhaseFlow } from "./phases";
const done: PhaseFlow = {
elapsedConnectMs: 12,
elapsedTlsMs: 40,
elapsedRequestMs: 41,
elapsedHeadersMs: 90,
elapsedMs: 120,
status: 200,
error: null,
cancelled: false,
streamKind: null,
};
describe("flowPhases", () => {
test("lists each phase with how long it took", () => {
expect(flowPhases(done).map((p) => [p.key, p.atMs, p.tookMs])).toEqual([
["start", 0, 0],
["connect", 12, 12],
["tls", 40, 28],
["request", 41, 1],
["headers", 90, 49],
["end", 120, 30],
]);
});
test("leaves out phases the engine has no time for", () => {
const phases = flowPhases({
...done,
elapsedConnectMs: null,
elapsedTlsMs: null,
elapsedRequestMs: null,
});
expect(phases.map((p) => p.key)).toEqual(["start", "headers", "end"]);
});
test("an open flow has no end yet", () => {
const phases = flowPhases({ ...done, elapsedMs: null });
expect(phases.at(-1)?.key).toBe("headers");
});
test("a failure ends with the engine's error", () => {
const phases = flowPhases({
...done,
elapsedTlsMs: null,
elapsedRequestMs: null,
elapsedHeadersMs: null,
elapsedMs: 31,
status: null,
error: "Connection refused",
});
expect(phases.at(-1)).toMatchObject({ kind: "error", atMs: 31, detail: "Connection refused" });
});
test("a stream closes", () => {
expect(flowPhases({ ...done, streamKind: "websocket" }).at(-1)?.label).toBe("Closed");
});
});
+66
View File
@@ -0,0 +1,66 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
export type PhaseKind = "start" | "step" | "end" | "error" | "cancelled";
/** One row of a flow's Timeline: a moment in the exchange, and how long it took to reach it */
export interface FlowPhase {
key: string;
label: string;
kind: PhaseKind;
/** Milliseconds from the start */
atMs: number;
/** Milliseconds since the phase before */
tookMs: number;
detail?: string;
}
export type PhaseFlow = Pick<
Flow,
| "elapsedHeadersMs"
| "elapsedMs"
| "status"
| "error"
| "cancelled"
| "streamKind"
| "elapsedConnectMs"
| "elapsedTlsMs"
| "elapsedRequestMs"
>;
/**
* The phases the engine recorded for a flow, in order: started, connected, TLS, request sent,
* headers, and how it ended. A phase the engine has no time for is left out; an open flow has
* no end yet.
*/
export function flowPhases(flow: PhaseFlow): FlowPhase[] {
const steps: Omit<FlowPhase, "tookMs">[] = [
{ key: "start", label: "Started", kind: "start", atMs: 0 },
];
const add = (key: string, label: string, atMs: number | null | undefined) => {
if (atMs != null) steps.push({ key, label, kind: "step", atMs });
};
add("connect", "Connected", flow.elapsedConnectMs);
add("tls", "TLS handshake", flow.elapsedTlsMs);
add("request", "Request sent", flow.elapsedRequestMs);
add(
"headers",
flow.status == null ? "Headers received" : `Headers received, ${flow.status}`,
flow.elapsedHeadersMs,
);
if (flow.elapsedMs != null || flow.error != null || flow.cancelled) {
const atMs = flow.elapsedMs ?? steps[steps.length - 1]?.atMs ?? 0;
if (flow.error != null) {
steps.push({ key: "end", label: "Failed", kind: "error", atMs, detail: flow.error });
} else if (flow.cancelled) {
steps.push({ key: "end", label: "Cancelled", kind: "cancelled", atMs });
} else {
const label = flow.streamKind == null ? "Body received" : "Closed";
steps.push({ key: "end", label, kind: "end", atMs });
}
}
// Times come from different clocks in the engine; keep them in order regardless
steps.sort((a, b) => a.atMs - b.atMs);
return steps.map((s, i) => ({ ...s, tookMs: i === 0 ? 0 : s.atMs - (steps[i - 1]?.atMs ?? 0) }));
}
+50 -7
View File
@@ -5,7 +5,10 @@ import { getDomain } from "tldts";
* A search over every flow in the database: the search box's terms, its field terms, and
* whether System's flows are hidden, as `list_flows` takes them.
*/
export type FlowQuery = Pick<FlowFilter, "q" | "under" | "app" | "system" | "tunnelled" | "any">;
export type FlowQuery = Pick<
FlowFilter,
"q" | "under" | "app" | "cwd" | "system" | "tunnelled" | "any"
>;
/** A word of the search box: a free term, or `@field:value`, maybe negated with `-` */
export interface SearchToken {
@@ -75,12 +78,14 @@ export function parseSearch(text: string, hideSystem = false): FlowQuery | null
function parseClause(tokens: SearchToken[], hideSystem: boolean): FlowQuery | null {
const words: string[] = [];
const apps: string[] = [];
const cwds: string[] = [];
// Each host with the paths written after it.
const hosts: { host: string; paths: string[] }[] = [];
let system: boolean | undefined = hideSystem ? false : undefined;
let tunnelled: boolean | undefined;
for (const t of tokens) {
if (t.field === "app" && !t.negated && t.value !== "") apps.push(t.value);
else if (t.field === "cwd" && !t.negated && t.value !== "") cwds.push(t.value);
else if (t.field === "host" && !t.negated && t.value !== "") {
hosts.push({ host: t.value.replace(/\/+$/, ""), paths: [] });
} else if (t.field === "path" && !t.negated && t.value !== "") {
@@ -107,6 +112,7 @@ function parseClause(tokens: SearchToken[], hideSystem: boolean): FlowQuery | nu
...(q === "" ? {} : { q }),
...(under.length > 0 ? { under } : {}),
...(apps.length > 0 ? { app: apps } : {}),
...(cwds.length > 0 ? { cwd: cwds } : {}),
...(system == null ? {} : { system }),
...(tunnelled == null ? {} : { tunnelled }),
};
@@ -136,19 +142,49 @@ export function widerQueries(text: string): string[] {
return [...out];
}
/** The folder a program outside an app ran in, which names it better than its executable */
/** The folder a program outside an app ran in */
export function folderOf(flow: Pick<Flow, "clientBundleId" | "clientCwd">): string | null {
if (flow.clientBundleId != null || flow.clientCwd == null) return null;
const name = flow.clientCwd.replace(/\/+$/, "").split("/").pop() ?? "";
if (flow.clientBundleId != null) return null;
return folderName(flow.clientCwd);
}
/** The last part of a path: `fieldwork` of `/src/fieldwork/` */
function folderName(path: string | null): string | null {
if (path == null) return null;
const name =
path
.replace(/[/\\]+$/, "")
.split(/[/\\]/)
.pop() ?? "";
return name === "" ? null : name;
}
/** Runtimes that run anyone's code, so six rows of `node` say nothing without their folder */
const GENERIC_RUNTIMES = new Set(["node", "python", "bun", "deno", "ruby", "java", "cargo"]);
/** A program outside an app that runs other code: `node`, `python3.12`, `java` */
export function isGenericRuntime(flow: Pick<Flow, "clientBundleId" | "clientName">): boolean {
if (flow.clientBundleId != null || flow.clientName == null) return false;
const name = flow.clientName.toLowerCase().replace(/\.exe$/, "");
return GENERIC_RUNTIMES.has(name.replace(/[\d.]+$/, ""));
}
/**
* What `@app` names a flow's app by, and what the sidebar groups its apps by: an app's name, or
* for a program outside an app the folder it ran in, else its name. Null when unknown.
* The folder that names a generic runtime's row, `node` in `fieldwork`; null for anything
* else, whose folders are rows under it
*/
export function runtimeFolderOf(
flow: Pick<Flow, "clientBundleId" | "clientName" | "clientCwd">,
): string | null {
return isGenericRuntime(flow) ? folderOf(flow) : null;
}
/**
* What `@app` names a flow's app by, and what the sidebar groups its apps by: an app's name, a
* program's name, or for a generic runtime the folder it ran in. Null when unknown.
*/
export function appKeyOf(flow: Flow): string | null {
return folderOf(flow) ?? flow.clientName ?? flow.clientBundleId ?? flow.clientPath;
return runtimeFolderOf(flow) ?? flow.clientName ?? flow.clientBundleId ?? flow.clientPath;
}
const SYSTEM_DIRS = ["/System/", "/usr/libexec/", "/usr/sbin/", "/sbin/", "/Library/Apple/"];
@@ -193,6 +229,7 @@ export function matchesQuery(flow: Flow, query: FlowQuery): boolean {
}
if (query.under != null && !query.under.some((node) => isUnder(flow.url, node))) return false;
if (query.app != null && !query.app.some((app) => matchesApp(flow, app))) return false;
if (query.cwd != null && !query.cwd.some((cwd) => matchesCwd(flow, cwd))) return false;
if (query.system != null && isSystemFlow(flow) !== query.system) return false;
if (query.tunnelled != null && flow.tunnelled !== query.tunnelled) return false;
// One of the groups, each judged whole, as the database judges them.
@@ -209,6 +246,12 @@ function matchesApp(flow: Flow, app: string): boolean {
return flow.clientName?.toLowerCase() === lower || folderOf(flow)?.toLowerCase() === lower;
}
/** By the whole working directory exactly, or its folder's name in any case */
function matchesCwd(flow: Flow, cwd: string): boolean {
if (flow.clientCwd == null) return false;
return flow.clientCwd === cwd || folderName(flow.clientCwd)?.toLowerCase() === cwd.toLowerCase();
}
/** `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;
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, test } from "vite-plus/test";
import {
CAPTURE_LIMIT_OPTIONS,
DEFAULT_SETTINGS_VALUES,
KEEP_TRAFFIC_OPTIONS,
proxySettingsView,
readSettingsValues,
sizeFromOptionValue,
sizeOptionsFor,
sizeOptionValue,
} from "./settings";
const APP = {
name: "Yaak Proxy",
version: "2026.11.0",
dataDir: "/data",
logDir: "/logs",
revealPathLabel: "Reveal in Finder",
};
describe("readSettingsValues", () => {
test("Yaak's defaults when nothing is stored", () => {
expect(readSettingsValues(null)).toEqual(DEFAULT_SETTINGS_VALUES);
expect(readSettingsValues("not json")).toEqual(DEFAULT_SETTINGS_VALUES);
expect(DEFAULT_SETTINGS_VALUES.interfaceFontSize).toBe(15);
expect(DEFAULT_SETTINGS_VALUES.editorFontSize).toBe(13);
});
test("stored values over the defaults, the wrong types ignored", () => {
const values = readSettingsValues(
JSON.stringify({ appearance: "dark", interfaceFontSize: "big", editorFont: "Menlo", x: 1 }),
);
expect(values.appearance).toBe("dark");
expect(values.interfaceFontSize).toBe(15);
expect(values.editorFont).toBe("Menlo");
expect("x" in values).toBe(false);
});
});
describe("proxySettingsView", () => {
test("resolves the system appearance and the themes", () => {
const view = proxySettingsView(DEFAULT_SETTINGS_VALUES, APP, "dark");
expect(view.themes?.resolvedAppearance).toBe("dark");
expect(view.themes?.active.dark).toBe(true);
const light = proxySettingsView(
{ ...DEFAULT_SETTINGS_VALUES, appearance: "light" },
APP,
"dark",
);
expect(light.themes?.resolvedAppearance).toBe("light");
expect(light.themes?.active.dark).toBe(false);
});
test("a missing theme falls back to Yaak's", () => {
const view = proxySettingsView({ ...DEFAULT_SETTINGS_VALUES, themeDark: "gone" }, APP, "dark");
expect(view.themes?.dark.id).toBe(DEFAULT_SETTINGS_VALUES.themeDark);
});
test("leaves out what the proxy doesn't have yet", () => {
const view = proxySettingsView(DEFAULT_SETTINGS_VALUES, APP, "light");
expect(view.updates).toBeNull();
expect(view.license).toBeNull();
expect(view.hiddenInterfaceRows).toEqual([
"editorKeymap",
"editorSoftWrap",
"coloredMethods",
"nativeTitlebar",
]);
expect(view.app.name).toBe("Yaak Proxy");
});
});
describe("size options", () => {
test("keep traffic is unlimited by default", () => {
expect(KEEP_TRAFFIC_OPTIONS[0]).toMatchObject({ label: "Unlimited", bytes: null });
expect(sizeOptionValue(null)).toBe("none");
expect(sizeFromOptionValue("none")).toBeNull();
});
test("labels in the units the limits are set in", () => {
expect(CAPTURE_LIMIT_OPTIONS.map((o) => o.label)).toEqual([
"1 MB",
"5 MB",
"10 MB",
"50 MB",
"100 MB",
]);
expect(KEEP_TRAFFIC_OPTIONS.map((o) => o.label)).toEqual([
"Unlimited",
"500 MB",
"1 GB",
"2 GB",
"5 GB",
"10 GB",
"20 GB",
]);
});
test("values round trip", () => {
for (const o of [...KEEP_TRAFFIC_OPTIONS, ...CAPTURE_LIMIT_OPTIONS]) {
expect(sizeFromOptionValue(sizeOptionValue(o.bytes))).toBe(o.bytes);
}
expect(sizeFromOptionValue("abc")).toBeNull();
});
test("a size set elsewhere gets its own option, in order", () => {
const options = sizeOptionsFor(CAPTURE_LIMIT_OPTIONS, 3 * 1024 * 1024);
expect(options.map((o) => o.bytes)).toEqual([
1024 * 1024,
3 * 1024 * 1024,
5 * 1024 * 1024,
10 * 1024 * 1024,
50 * 1024 * 1024,
100 * 1024 * 1024,
]);
expect(sizeOptionsFor(CAPTURE_LIMIT_OPTIONS, 10 * 1024 * 1024)).toBe(CAPTURE_LIMIT_OPTIONS);
});
});
+168
View File
@@ -0,0 +1,168 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
// The themes alone, not the package index, which reaches for the window as it loads
import { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme/src/defaultThemes";
import type {
SettingsAppInfo,
SettingsThemeOption,
SettingsValues,
SettingsView,
} from "@yaakapp-internal/ui";
/** Yaak's own defaults for the shared tabs, so the two apps start the same */
export const DEFAULT_SETTINGS_VALUES: SettingsValues = {
appearance: "system",
themeLight: defaultLightTheme.id,
themeDark: defaultDarkTheme.id,
interfaceFont: null,
interfaceFontSize: 15,
editorFont: null,
editorFontSize: 13,
editorKeymap: "default",
editorSoftWrap: true,
coloredMethods: false,
useNativeTitlebar: false,
hideWindowControls: false,
updateChannel: "stable",
autoupdate: true,
autoDownloadUpdates: true,
checkNotifications: true,
promptFeedback: true,
};
/** Where the shared tabs' values are kept in the proxy */
export const SETTINGS_VALUES_KEY = "proxy_settings_values";
/** The stored values over the defaults; anything unreadable or of the wrong type is ignored */
export function readSettingsValues(raw: string | null): SettingsValues {
if (raw == null) return DEFAULT_SETTINGS_VALUES;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return DEFAULT_SETTINGS_VALUES;
}
if (parsed == null || typeof parsed !== "object") return DEFAULT_SETTINGS_VALUES;
const out: Record<string, unknown> = { ...DEFAULT_SETTINGS_VALUES };
for (const [key, fallback] of Object.entries(DEFAULT_SETTINGS_VALUES)) {
const value = (parsed as Record<string, unknown>)[key];
const nullable = fallback === null;
if (value === undefined) continue;
if (
(nullable && (value === null || typeof value === "string")) ||
typeof value === typeof fallback
) {
out[key] = value;
}
}
return out as unknown as SettingsValues;
}
const THEMES: SettingsThemeOption[] = [defaultDarkTheme, defaultLightTheme].map((t) => ({
id: t.id,
label: t.label,
dark: t.dark,
}));
/** The appearance showing, with `system` resolved by the OS's */
export function resolveAppearance(appearance: string, system: "light" | "dark"): "light" | "dark" {
return appearance === "light" || appearance === "dark" ? appearance : system;
}
/**
* The proxy's settings as the shared tabs render them. The proxy has Yaak's two themes, no
* installed-font list, updater or feedback prompts yet, and its license comes with the license
* work, so those parts are left out, as are the Interface rows it doesn't act on.
*/
export function proxySettingsView(
values: SettingsValues,
app: SettingsAppInfo,
systemAppearance: "light" | "dark",
): SettingsView {
const light = THEMES.find((t) => t.id === values.themeLight && !t.dark) ?? lightOption();
const dark = THEMES.find((t) => t.id === values.themeDark && t.dark) ?? darkOption();
const resolvedAppearance = resolveAppearance(values.appearance, systemAppearance);
return {
app,
values,
themes: {
themes: THEMES,
light,
dark,
active: resolvedAppearance === "dark" ? dark : light,
resolvedAppearance,
},
fonts: null,
windowControlsSetting: false,
updates: null,
feedbackPrompts: false,
license: null,
// Not acted on in the proxy yet: its editors take no keymap or wrap setting, its list draws
// methods uncoloured, and it always draws its own title bar.
hiddenInterfaceRows: ["editorKeymap", "editorSoftWrap", "coloredMethods", "nativeTitlebar"],
};
}
function lightOption(): SettingsThemeOption {
return THEMES.find((t) => !t.dark) as SettingsThemeOption;
}
function darkOption(): SettingsThemeOption {
return THEMES.find((t) => t.dark) as SettingsThemeOption;
}
/** A size choice in a select, `null` for no limit */
export interface SizeOption {
label: string;
value: string;
bytes: number | null;
}
const MB = 1024 * 1024;
const GB = 1024 * MB;
function sizeOptions(sizes: (number | null)[], none: string): SizeOption[] {
return sizes.map((bytes) => ({
label: bytes == null ? none : sizeLabel(bytes),
value: bytes == null ? "none" : `${bytes}`,
bytes,
}));
}
/** A size in the binary units the limits are set in, so 10 MiB reads "10 MB", not "10.5 MB" */
export function sizeLabel(bytes: number): string {
if (bytes >= GB && bytes % (GB / 2) === 0) return `${bytes / GB} GB`;
if (bytes >= MB && bytes % MB === 0) return `${bytes / MB} MB`;
return formatSize(bytes);
}
/** How much captured traffic is kept before the oldest flows are dropped */
export const KEEP_TRAFFIC_OPTIONS = sizeOptions(
[null, 500 * MB, GB, 2 * GB, 5 * GB, 10 * GB, 20 * GB],
"Unlimited",
);
/** How much of each body is stored */
export const CAPTURE_LIMIT_OPTIONS = sizeOptions(
[MB, 5 * MB, 10 * MB, 50 * MB, 100 * MB],
"Unlimited",
);
/**
* The option a stored size selects. A size set elsewhere (the CLI or `settings.json`) that no
* option has is added as its own, so the select still shows it.
*/
export function sizeOptionsFor(options: SizeOption[], bytes: number | null): SizeOption[] {
if (options.some((o) => o.bytes === bytes)) return options;
const extra = sizeOptions([bytes], "Unlimited");
return [...options, ...extra].sort((a, b) => (a.bytes ?? Infinity) - (b.bytes ?? Infinity));
}
export function sizeOptionValue(bytes: number | null): string {
return bytes == null ? "none" : `${bytes}`;
}
export function sizeFromOptionValue(value: string): number | null {
if (value === "none") return null;
const n = Number(value);
return Number.isFinite(n) && n > 0 ? n : null;
}
+65
View File
@@ -103,6 +103,71 @@ describe("buildSidebarTree", () => {
});
});
describe("programs outside an app", () => {
const claude = (cwd: string) => ({
clientName: "claude",
clientPath: "/usr/local/bin/claude",
clientCwd: cwd,
});
const python = {
clientName: "python3.12",
clientPath: "/usr/bin/python3.12",
clientCwd: "/src/ml",
};
const curl = { clientName: "curl", clientPath: "/usr/bin/curl", clientCwd: null };
const root = buildSidebarTree(
[
flow("https://api.anthropic.com/v1/messages", claude("/src/yaak")),
flow("https://api.anthropic.com/v1/models", claude("/src/fieldwork")),
flow("https://statsig.anthropic.com/v1", claude("/src/yaak")),
flow("https://pypi.org/simple", python),
flow("https://example.com/", curl),
flow("https://api.github.com/repos", node),
],
6,
);
const items = itemsById(root);
const apps = (root.children ?? []).find((c) => c.item.id === "apps");
test("one row per program, its working directories one level down above the hosts", () => {
expect(shape(apps as TreeNode<SidebarItem>).slice(0, 12)).toEqual([
"Apps 6",
" claude 3",
" fieldwork 1",
" anthropic.com 1",
" api.anthropic.com 1",
" /v1 1",
" /models 1",
" yaak 2",
" anthropic.com 2",
" api.anthropic.com 1",
" /v1 1",
" /messages 1",
]);
});
test("generic runtimes keep their folder in the row's name", () => {
const labels = (apps?.children ?? []).map((c) => [c.item.label, c.item.sublabel ?? null]);
expect(labels).toEqual([
["claude", null],
["curl", null],
["node", "fieldwork"],
["python3.12", "ml"],
]);
// A program with no working directory has its hosts straight under it.
expect(items.get("app:curl|site:example.com")?.kind).toBe("site");
});
test("a folder row selects that program in that folder", () => {
const query = items.get("app:claude|cwd:yaak")?.query ?? "";
expect(query).toBe("@app:claude @cwd:yaak");
expect(items.get("app:claude|cwd:yaak|host:statsig.anthropic.com")?.query).toBe(
"@app:claude @cwd:yaak @host:statsig.anthropic.com",
);
expect(parseSearch(query)).toEqual({ app: ["claude"], cwd: ["yaak"] });
});
});
describe("decrypting from the sidebar", () => {
const tunnel = flow("https://bank.example.com", {
...slack,
+44 -11
View File
@@ -6,19 +6,31 @@ import {
folderOf,
formatFieldFilter,
isSystemFlow,
runtimeFolderOf,
sidebarPath,
siteOf,
tokenize,
} from "./search";
export type SidebarKind = "all" | "tunnelled" | "group" | "app" | "site" | "host" | "path";
export type SidebarKind =
| "all"
| "tunnelled"
| "group"
| "app"
| "folder"
| "site"
| "host"
| "path";
/** A row of the sidebar: All traffic, a group, an app, or a site, host or path under one */
/**
* A row of the sidebar: All traffic, a group, an app, a program's working directory, or a site,
* host or path under one
*/
export interface SidebarItem {
id: string;
kind: SidebarKind;
label: string;
/** Dim beside the label: a program's folder */
/** Dim beside the label: a generic runtime's folder */
sublabel?: string;
/** Flows under the row, of those loaded */
count: number;
@@ -42,7 +54,7 @@ export const TUNNELLED_ID = "tunnelled";
/** A row a flow sits under, with what makes it, so a rebuild only looks it up */
interface PlaceRow {
id: string;
kind: "site" | "host" | "path";
kind: "folder" | "site" | "host" | "path";
label: string;
query: string;
decrypt?: DecryptSubject;
@@ -50,7 +62,8 @@ interface PlaceRow {
/**
* Where a flow sits, parsed once per flow rather than on every rebuild: its site, host and path
* rows in Domains, and the same rows under its app.
* rows in Domains, and the same rows under its app, below its working directory for a program
* outside an app that isn't a generic runtime (those are named by their folder instead).
*/
interface Place {
/** What `@app` names its app by, or null when unknown */
@@ -99,10 +112,28 @@ function placeOf(flow: Flow): Place {
}
const system = isSystemFlow(flow);
const appQuery = app == null ? "" : appQueryFor(app, system);
const appRows =
app == null
? []
: rows.map((r) => ({ ...r, id: `app:${app}|${r.id}`, query: `${appQuery} ${r.query}` }));
let appRows: PlaceRow[] = [];
if (app != null) {
const folder = runtimeFolderOf(flow) == null ? folderOf(flow) : null;
if (folder == null) {
appRows = rows.map((r) => ({
...r,
id: `app:${app}|${r.id}`,
query: `${appQuery} ${r.query}`,
}));
} else {
const folderId = `app:${app}|cwd:${folder}`;
const folderQuery = `${appQuery} ${formatFieldFilter("cwd", folder)}`;
appRows = [
{ id: folderId, kind: "folder", label: folder, query: folderQuery },
...rows.map((r) => ({
...r,
id: `${folderId}|${r.id}`,
query: `${folderQuery} ${r.query}`,
})),
];
}
}
place = { app, system, rows, appRows };
places.set(flow, place);
return place;
@@ -164,8 +195,10 @@ function toNodes(
});
}
/** By label, a program's working directories before the sites it reached directly */
function byLabel(a: Trie, b: Trie): number {
return collator.compare(a.item.label, b.item.label);
const folders = Number(b.item.kind === "folder") - Number(a.item.kind === "folder");
return folders || collator.compare(a.item.label, b.item.label);
}
function group(id: string, label: string): Trie {
@@ -203,7 +236,7 @@ export function buildSidebarTree(flows: Flow[], total: number): TreeNode<Sidebar
const id = `app:${place.app}`;
let app = parent.children.get(id);
if (app == null) {
const folder = folderOf(flow);
const folder = runtimeFolderOf(flow);
const item: SidebarItem = {
id,
kind: "app",
+80 -15
View File
@@ -198,6 +198,37 @@ export const pausedAtAtom = atom<number | null>(null);
*/
let changesDuringPage: ModelPayload[] | null = null;
/**
* Flows the keep-traffic sweep deleted while a page was being read: the page may be a snapshot
* from before, so it is filtered by these as it lands. Forgotten once no page is being read.
*/
const removedWhileLoading = new Set<string>();
let pagesLoading = 0;
function pageStarted() {
pagesLoading++;
}
function pageSettled() {
pagesLoading = Math.max(0, pagesLoading - 1);
if (pagesLoading === 0) removedWhileLoading.clear();
}
/** A page's flows less any the sweep deleted since it was asked for */
function withoutRemoved(flows: Flow[]): Flow[] {
return removedWhileLoading.size === 0
? flows
: flows.filter((f) => !removedWhileLoading.has(f.id));
}
/** Changes still to apply, less the ones that would bring back a deleted flow */
function dropRemovedChanges(changes: ModelPayload[], removed: Set<string>) {
for (let i = changes.length - 1; i >= 0; i--) {
const c = changes[i];
if (c != null && c.change.type !== "delete" && removed.has(c.model.id)) changes.splice(i, 1);
}
}
/** 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);
@@ -206,12 +237,13 @@ export const loadOlderFlowsAtom = atom(null, async (get, set) => {
if (changesDuringPage != null || !hasOlder || first == null) return;
const changes: ModelPayload[] = [];
changesDuringPage = changes;
pageStarted();
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 merged = prependOlder(win, withoutRemoved(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 };
@@ -225,6 +257,7 @@ export const loadOlderFlowsAtom = atom(null, async (get, set) => {
console.warn("Failed to load older flows", err);
} finally {
changesDuringPage = null;
pageSettled();
}
});
@@ -319,21 +352,49 @@ export function syncFlows(store: Store) {
load();
});
// The oldest flows, swept to keep the capture under its size limit: dropped wherever they
// are held, and the totals counted again from the database.
listen("flows_removed", ({ ids }) => {
const removed = new Set(ids);
const drop = (win: FlowWindow) =>
win.flows.some((f) => removed.has(f.id))
? { ...win, flows: win.flows.filter((f) => !removed.has(f.id)) }
: win;
if (pagesLoading > 0) for (const id of ids) removedWhileLoading.add(id);
dropRemovedChanges(pending, removed);
if (changesDuringPage != null) dropRemovedChanges(changesDuringPage, removed);
store.set(flowWindowAtom, drop);
store.set(searchWindowAtom, (s) => {
if (s == null) return s;
const buffered = [...s.buffered];
dropRemovedChanges(buffered, removed);
return { ...s, win: drop(s.win), buffered };
});
if (removed.has(store.get(selectedFlowAtom)?.id ?? "")) store.set(selectedFlowAtom, null);
if (recountTimer == null) recountTimer = setTimeout(recount, RECOUNT_MS);
});
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);
// Flows recorded before this install remembered it mean setup was done.
if (res.total > 0) store.set(systemProxyEverOnAtom, true);
flush();
recount();
});
pageStarted();
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(withoutRemoved(res.flows), WINDOW_SIZE, res.total),
);
seenId = res.flows[0]?.id ?? null;
loaded = true;
store.set(flowsLoadedAtom, true);
// Flows recorded before this install remembered it mean setup was done.
if (res.total > 0) store.set(systemProxyEverOnAtom, true);
flush();
recount();
})
.finally(pageSettled);
loadSearch(store.get(activeQueryAtom));
}
reloadFlows = load;
@@ -360,12 +421,16 @@ export function syncFlows(store: Store) {
buffered: [],
};
});
pageStarted();
void rpc("list_flows", { ...query, limit: WINDOW_SIZE })
.then((res) => settle(windowFromPage(res.flows, WINDOW_SIZE, res.matching ?? res.total)))
.then((res) =>
settle(windowFromPage(withoutRemoved(res.flows), WINDOW_SIZE, res.matching ?? res.total)),
)
.catch((err) => {
console.warn("Failed to search flows", err);
settle(null);
});
})
.finally(pageSettled);
}
// The sidebar takes the window's flows at once when it empties or first fills, and at most
+14 -3
View File
@@ -9,24 +9,35 @@ import {
subscribeToPreferredAppearance,
type Appearance,
} from "@yaakapp-internal/theme";
import { readSettingsValues, resolveAppearance, SETTINGS_VALUES_KEY } from "./settings";
import { showWindow } from "./tauri";
setPlatformOnDocument(platformFromUserAgent(navigator.userAgent));
// The appearance chosen in Settings, `system` following the OS's
let chosenAppearance = readSettingsValues(localStorage.getItem(SETTINGS_VALUES_KEY)).appearance;
// Apply a quick initial theme based on CSS media query
let preferredAppearance: Appearance = getCSSAppearance();
applyTheme(preferredAppearance);
applyTheme();
// Then subscribe to accurate OS appearance detection and changes
subscribeToPreferredAppearance((a) => {
preferredAppearance = a;
applyTheme(preferredAppearance);
applyTheme();
});
// Show window after initial theme is applied (window starts hidden to prevent flash)
showWindow().catch(console.error);
function applyTheme(appearance: Appearance) {
/** Applies the appearance chosen in Settings: `light`, `dark` or `system` */
export function setChosenAppearance(appearance: string) {
chosenAppearance = appearance;
applyTheme();
}
function applyTheme() {
const appearance = resolveAppearance(chosenAppearance, preferredAppearance);
const theme = appearance === "dark" ? defaultDarkTheme : defaultLightTheme;
applyThemeToDocument(theme);
if (theme.base.surface != null) {
+22 -1
View File
@@ -48,7 +48,28 @@ elapsedHeadersMs: number | null,
/**
* Milliseconds from `started_at` to the end of the response.
*/
elapsedMs: number | null, error: string | null,
elapsedMs: number | null,
/**
* Milliseconds from `started_at` to the TCP connection to the server, and to the end of
* its TLS handshake. Only for intercepted HTTPS on a connection opened for this flow.
*/
elapsedConnectMs: number | null, elapsedTlsMs: number | null,
/**
* Milliseconds from `started_at` to the request body being sent whole. None without one.
*/
elapsedRequestMs: number | null,
/**
* The client's end of its connection: `127.0.0.1:54321`.
*/
clientAddr: string | null,
/**
* The TLS negotiated with the server for intercepted HTTPS: `TLSv1.3`.
*/
upstreamTlsVersion: string | null,
/**
* `TLS13_AES_128_GCM_SHA256`.
*/
upstreamTlsCipher: string | 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.
+47 -2
View File
@@ -170,6 +170,11 @@ under?: Array<string>,
* it ran in (`fieldwork`).
*/
app?: Array<string>,
/**
* Only flows from a process that ran in one of these working directories: the whole path,
* or its folder's name in any case (`fieldwork`). With `app`, a program in a folder.
*/
cwd?: Array<string>,
/**
* Only flows from macOS's own daemons (true), or none of them (false), as [`is_system`]
* tells them by their executable.
@@ -212,6 +217,11 @@ before?: string, };
export type FlowsClearedPayload = Record<string, never>;
/**
* Flows deleted to keep captured traffic under the keep-traffic cap, the oldest first.
*/
export type FlowsRemovedPayload = { ids: Array<string>, };
export type FolderSummary = { id: string, name: string,
/**
* The folder this one is in, if any.
@@ -282,6 +292,14 @@ systemProxyAtLaunch: boolean,
* The HTTP versions offered to clients.
*/
httpVersion: HttpVersion,
/**
* Bytes of each body stored per flow.
*/
captureBodyLimit: number,
/**
* The most captured traffic kept, in bytes, or null to keep everything.
*/
keepTrafficBytes: number | null,
/**
* Why the engine last failed to start, until it starts.
*/
@@ -301,6 +319,15 @@ locked: ProxyLock | null,
*/
otherOwner: OtherOwner | null, };
export type GetStorageSizeRequest = Record<string, never>;
export type GetStorageSizeResponse = {
/**
* The databases' used pages, as the keep-traffic cap counts them; locked, their files'
* sizes on disk.
*/
bytes: number, };
export type GlobalAction = "recording_toggle" | "flows_clear";
export type HelperRegisterRequest = Record<string, never>;
@@ -370,6 +397,12 @@ yaakFound: boolean, workspaces: Array<WorkspaceSummary>, };
*/
export type MessagePayload = { message: FlowMessage, };
export type OpenTerminalRequest = {
/**
* Environment variables the shell starts with, by name: `HTTPS_PROXY`.
*/
vars: Array<[string, string]>, };
export type OtherOwner = { owner: Owner, pid: number, startedAt: string,
/**
* The engine's port while it runs.
@@ -412,9 +445,11 @@ host: string, };
export type RemoveGrpcSchemaRequest = { id: string, };
export type RpcEventSchema = { decrypt_list_changed: DecryptListChangedPayload, flows_cleared: FlowsClearedPayload, message_write: MessagePayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, };
export type ResetCapturedDataRequest = Record<string, never>;
export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_decrypt_list: [GetDecryptListRequest, DecryptList], get_message_payload: [GetMessagePayloadRequest, GetMessagePayloadResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], list_grpc_schemas: [ListGrpcSchemasRequest, Array<GrpcSchema>], list_messages: [ListMessagesRequest, ListMessagesResponse], remove_grpc_schema: [RemoveGrpcSchemaRequest, boolean], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], add_grpc_schema: [AddGrpcSchemaRequest, GrpcSchema], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], decode_grpc_message: [DecodeGrpcMessageRequest, DecodedGrpcMessage], 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], edit_decrypt_list: [EditDecryptListRequest, DecryptList], list_seen: [ListSeenRequest, SeenTargets], set_decrypt_list: [SetDecryptListRequest, DecryptList], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], reflect_grpc_host: [ReflectGrpcHostRequest, GrpcSchema], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], };
export type RpcEventSchema = { decrypt_list_changed: DecryptListChangedPayload, flows_cleared: FlowsClearedPayload, flows_removed: FlowsRemovedPayload, message_write: MessagePayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, };
export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_decrypt_list: [GetDecryptListRequest, DecryptList], get_message_payload: [GetMessagePayloadRequest, GetMessagePayloadResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], list_grpc_schemas: [ListGrpcSchemasRequest, Array<GrpcSchema>], list_messages: [ListMessagesRequest, ListMessagesResponse], remove_grpc_schema: [RemoveGrpcSchemaRequest, boolean], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], set_capture_body_limit: [SetCaptureBodyLimitRequest, number], set_keep_traffic: [SetKeepTrafficRequest, number | null], add_grpc_schema: [AddGrpcSchemaRequest, GrpcSchema], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], decode_grpc_message: [DecodeGrpcMessageRequest, DecodedGrpcMessage], 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], edit_decrypt_list: [EditDecryptListRequest, DecryptList], list_seen: [ListSeenRequest, SeenTargets], set_decrypt_list: [SetDecryptListRequest, DecryptList], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], reflect_grpc_host: [ReflectGrpcHostRequest, GrpcSchema], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], set_port: [SetPortRequest, number], get_storage_size: [GetStorageSizeRequest, GetStorageSizeResponse], reset_captured_data: [ResetCapturedDataRequest, boolean], open_terminal: [OpenTerminalRequest, boolean], };
export type SaveFlowBodyRequest = { flowId: string, which: FlowBody,
/**
@@ -504,6 +539,8 @@ name: string,
*/
id?: string, http: ProxySetting, https: ProxySetting, };
export type SetCaptureBodyLimitRequest = { bytes: number, };
export type SetDecryptListRequest = {
/**
* Every entry, in order, as typed: a bare hostname is a `host:` entry. A built-in
@@ -513,6 +550,14 @@ entries: Array<string>, };
export type SetHttpVersionRequest = { httpVersion: HttpVersion, };
export type SetKeepTrafficRequest = {
/**
* Null keeps everything.
*/
bytes: number | null, };
export type SetPortRequest = { port: number, };
export type SetRecordingRequest = { enabled: boolean, };
export type SetSystemProxyAtLaunchRequest = { enabled: boolean, };
@@ -0,0 +1,8 @@
-- The client's address, when the request was sent and the upstream connection made, and the
-- TLS it negotiated, for the flow's timeline and details.
ALTER TABLE flows ADD COLUMN client_addr TEXT;
ALTER TABLE flows ADD COLUMN elapsed_connect_ms INTEGER;
ALTER TABLE flows ADD COLUMN elapsed_tls_ms INTEGER;
ALTER TABLE flows ADD COLUMN elapsed_request_ms INTEGER;
ALTER TABLE flows ADD COLUMN upstream_tls_version TEXT;
ALTER TABLE flows ADD COLUMN upstream_tls_cipher TEXT;
@@ -0,0 +1,7 @@
-- Flows the keep-traffic sweep deleted whose bodies in `blobs.sqlite` may not be deleted yet:
-- written with the rows' deletion and removed once their bodies are, so a sweep that fails
-- between the two finishes the bodies next time.
CREATE TABLE flow_sweep_pending
(
flow_id TEXT NOT NULL PRIMARY KEY
);
+73 -4
View File
@@ -165,6 +165,9 @@ fn missing_data_key(data_dir: &Path) -> String {
pub struct Stores {
pub db: ProxyQueryManager,
pub blobs: BlobManager,
/// `blobs.sqlite` and its key, for measuring it: its pools are its manager's own.
blobs_path: PathBuf,
key: Option<DatabaseKey>,
}
impl Stores {
@@ -173,9 +176,26 @@ impl Stores {
pub fn open(data_dir: &Path, key: &DatabaseKey) -> std::result::Result<Self, String> {
let db = ProxyQueryManager::new(&data_dir.join(PROXY_DB), key)
.map_err(|e| format!("Failed to open proxy database: {e}"))?;
let blobs = open_blobs(&data_dir.join(BLOBS_DB), key)
let blobs_path = data_dir.join(BLOBS_DB);
let blobs = open_blobs(&blobs_path, key)
.map_err(|e| format!("Failed to open proxy blob store: {e}"))?;
Ok(Self { db, blobs })
Ok(Self { db, blobs, blobs_path, key: Some(key.clone()) })
}
/// Bytes the captured traffic takes, as [`used_bytes`] counts them, in both databases.
pub fn used_bytes(&self) -> std::result::Result<u64, String> {
let fail = |e: rusqlite::Error| format!("Failed to measure captured traffic: {e}");
let flows = self.db.with_conn(|c| used_bytes(c.conn().resolve())).map_err(fail)?;
let conn = rusqlite::Connection::open_with_flags(
&self.blobs_path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(fail)?;
if let Some(key) = &self.key {
encryption::apply_key(&conn, key).map_err(fail)?;
}
conn.busy_timeout(Duration::from_millis(5000)).map_err(fail)?;
Ok(flows + used_bytes(&conn).map_err(fail)?)
}
/// The databases in `data_dir` for reading, with the data key the master key in `keys`
@@ -198,10 +218,58 @@ impl Stores {
.map_err(|e| format!("Failed to open {}: {e}", db_path.display()))?;
let blobs = open_blobs_read_only(&blobs_path, key.as_ref())
.map_err(|e| format!("Failed to open the proxy's blob store: {e}"))?;
Ok(Self { db, blobs })
Ok(Self { db, blobs, blobs_path, key })
}
}
/// Bytes the data in a database takes: its pages less the free ones. SQLite reuses free pages
/// but gives none back to the file without a vacuum, so this shrinks as rows are deleted where
/// the file does not. The WAL is left out, as its pages are copies of ones counted here.
pub fn used_bytes(conn: &rusqlite::Connection) -> rusqlite::Result<u64> {
use rusqlite::types::Value;
// SQLCipher answers `page_size` with its own as text.
let pragma = |name: &str| {
let value = conn.query_row(&format!("PRAGMA {name}"), [], |r| r.get::<_, Value>(0))?;
let n = match &value {
Value::Integer(n) => Some(*n),
Value::Text(s) => s.trim().parse().ok(),
_ => None,
};
n.ok_or_else(|| rusqlite::Error::InvalidColumnType(0, name.into(), value.data_type()))
};
let used = (pragma("page_count")? - pragma("freelist_count")?) * pragma("page_size")?;
Ok(used.max(0) as u64)
}
/// The databases' files and what goes with them: their WAL and shared memory.
fn database_files(data_dir: &Path) -> impl Iterator<Item = PathBuf> + '_ {
[PROXY_DB, BLOBS_DB]
.into_iter()
.flat_map(|name| ["", "-wal", "-shm"].map(|suffix| format!("{name}{suffix}")))
.map(move |name| data_dir.join(name))
}
/// Bytes the databases' files take on disk, free pages and WAL included, for when they are
/// locked and cannot be measured as [`Stores::used_bytes`] does.
pub fn file_bytes(data_dir: &Path) -> u64 {
database_files(data_dir).filter_map(|p| std::fs::metadata(p).ok()).map(|m| m.len()).sum()
}
/// Deletes the databases and the data key that encrypts them, for good. Nothing may write to
/// them meanwhile.
pub fn remove_captured(data_dir: &Path) -> std::result::Result<(), String> {
let key = data_key_path(data_dir);
for path in database_files(data_dir).chain([key.with_extension("tmp"), key]) {
match std::fs::remove_file(&path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
return Err(format!("Failed to delete {}: {e}", path.display()));
}
_ => {}
}
}
Ok(())
}
/// Either database in `data_dir` is plain, left by a build from before encryption.
pub fn needs_encrypting(data_dir: &Path) -> bool {
[PROXY_DB, BLOBS_DB]
@@ -443,6 +511,7 @@ mod tests {
"_sqlx_migrations",
"flow_clear_mark",
"flow_messages",
"flow_sweep_pending",
"flows",
"grpc_schemas"
]
@@ -530,7 +599,7 @@ mod tests {
.unwrap()
.map(|v| v.unwrap())
.collect();
assert_eq!(&applied[applied.len() - 3..], ["00000007", "00000008", "00000009"]);
assert_eq!(&applied[6..], ["00000007", "00000008", "00000009", "00000010", "00000011"]);
let mark_columns = columns(&conn, "flow_clear_mark");
assert_eq!(mark_columns, ["id", "max_rowid", "cleaner", "cleaner_at"]);
drop(conn);
@@ -0,0 +1,137 @@
//! Keeps background database work from running into SQLCipher's teardown as the process exits.
//!
//! SQLCipher frees its global state (the key shield, its private heap) in an `atexit` handler it
//! registers the first time a database is keyed. A thread still reading or writing a database,
//! or closing its last connection, then crashes inside SQLCipher. The threads this crate starts
//! (the event loop writing flows, deleting cleared flows, keeping traffic under its cap) hold
//! [`hold`] around each unit of work. [`install`], called once a database is open and so after
//! SQLCipher's handler, registers one that runs before it, as handlers run last registered
//! first: it stops any more work starting, waits for work in flight to finish, for a few seconds
//! at most, and lets the process exit with that work parked.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Once, RwLock, RwLockReadGuard, TryLockError};
use std::time::{Duration, Instant};
static GATE: RwLock<()> = RwLock::new(());
/// Set as the process exits: no more work starts.
static EXITING: AtomicBool = AtomicBool::new(false);
static INSTALL: Once = Once::new();
/// The longest the exit handler waits for work in flight before letting the process go on
/// exiting regardless, so a stuck thread never hangs a quit.
const EXIT_WAIT: Duration = Duration::from_secs(5);
/// Held around a unit of background database work, a batch, closing connections included. Once
/// the process is exiting, this parks the thread for good instead: the work never starts.
pub(crate) fn hold() -> RwLockReadGuard<'static, ()> {
if EXITING.load(Ordering::SeqCst) {
loop {
std::thread::park();
}
}
GATE.read().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Registers the exit handler, once per process. Call after a database is open, so SQLCipher's
/// own handler is registered first and runs after this one.
pub(crate) fn install() {
INSTALL.call_once(|| {
// SAFETY: `close_gate` is an `extern "C" fn()` that never unwinds.
let failed = unsafe { atexit(close_gate) } != 0;
if failed {
log::warn!("Failed to register the database exit handler");
}
});
}
unsafe extern "C" {
fn atexit(callback: extern "C" fn()) -> std::ffi::c_int;
}
/// Stops more work starting, then waits for the work in flight to finish its batch and keeps
/// the gate shut for the rest of the process's life.
extern "C" fn close_gate() {
EXITING.store(true, Ordering::SeqCst);
wait_for_work(EXIT_WAIT);
}
/// Takes the gate for good once no work holds it, or gives up after `wait`. Returns whether it
/// was taken. Polled, as a blocking write can't be given up on; with no new work starting, the
/// work in flight can't starve it.
fn wait_for_work(wait: Duration) -> bool {
let deadline = Instant::now() + wait;
loop {
match GATE.try_write() {
Ok(guard) => {
std::mem::forget(guard);
return true;
}
Err(TryLockError::Poisoned(e)) => {
std::mem::forget(e.into_inner());
return true;
}
Err(TryLockError::WouldBlock) if Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(1));
}
Err(TryLockError::WouldBlock) => return false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::mpsc::channel;
const SELF: &str = "exit_gate::tests::exiting_waits_for_work_in_flight_and_starts_no_more";
/// A batch in flight finishes before the gate shuts, however busy other work keeps it, and
/// no work starts after. Run in a process of its own, as shutting the gate parks every
/// thread that would hold it, which the other tests' threads would be.
#[test]
fn exiting_waits_for_work_in_flight_and_starts_no_more() {
if std::env::var("YAAK_EXIT_GATE_CHILD").is_err() {
let status = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", SELF, "--test-threads", "1"])
.env("YAAK_EXIT_GATE_CHILD", "1")
.status()
.unwrap();
assert!(status.success());
return;
}
// Busy work, taking the gate again as soon as it lets go, as the event loop does.
let busy = Arc::new(AtomicBool::new(false));
for _ in 0..4 {
let busy = busy.clone();
std::thread::spawn(move || {
loop {
let _held = hold();
busy.store(true, Ordering::SeqCst);
}
});
}
// A batch in flight, a slow one.
let (holding_tx, holding_rx) = channel();
let (finished_tx, finished_rx) = channel();
std::thread::spawn(move || {
let _batch = hold();
holding_tx.send(()).unwrap();
std::thread::sleep(Duration::from_millis(200));
finished_tx.send(Instant::now()).unwrap();
});
holding_rx.recv().unwrap();
while !busy.load(Ordering::SeqCst) {
std::hint::spin_loop();
}
EXITING.store(true, Ordering::SeqCst);
assert!(wait_for_work(Duration::from_secs(5)), "not starved by the busy work");
let finished = finished_rx.try_recv().expect("the batch finished before the gate shut");
assert!(finished <= Instant::now());
// Nothing holds the gate after, the busy work included: it is all parked.
assert!(GATE.try_read().is_err(), "shut for good");
}
}
+249 -4
View File
@@ -1,5 +1,5 @@
use crate::bodies;
use crate::db::ProxyQueryManager;
use crate::db::{ProxyQueryManager, Stores};
use crate::models::{Flow, FlowBody};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
@@ -31,6 +31,10 @@ pub struct FlowFilter {
/// it ran in (`fieldwork`).
#[ts(optional)]
pub app: Option<Vec<String>>,
/// Only flows from a process that ran in one of these working directories: the whole path,
/// or its folder's name in any case (`fieldwork`). With `app`, a program in a folder.
#[ts(optional)]
pub cwd: Option<Vec<String>>,
/// Only flows from macOS's own daemons (true), or none of them (false), as [`is_system`]
/// tells them by their executable.
#[ts(optional)]
@@ -65,8 +69,8 @@ pub struct FlowFilter {
}
impl FlowFilter {
/// The filter from a query string's `q`, `host`, `under`, `app`, `system`, `method`,
/// `status`, `limit`, `after` and `before`. `under` and `app` may repeat.
/// The filter from a query string's `q`, `host`, `under`, `app`, `cwd`, `system`, `method`,
/// `status`, `limit`, `after` and `before`. `under`, `app` and `cwd` 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()) {
@@ -76,6 +80,7 @@ impl FlowFilter {
"host" => filter.host = value,
"under" => filter.under.get_or_insert_default().extend(value),
"app" => filter.app.get_or_insert_default().extend(value),
"cwd" => filter.cwd.get_or_insert_default().extend(value),
"system" => {
filter.system = value
.map(|v| v.parse().map_err(|_| format!("Invalid system {v:?}")))
@@ -116,7 +121,11 @@ impl FlowFilter {
query.append_pair(key, value);
}
}
for (key, values) in [("under", &self.under), ("app", &self.app)] {
for (key, values) in [
("under", &self.under),
("app", &self.app),
("cwd", &self.cwd),
] {
for value in values.iter().flatten() {
query.append_pair(key, value);
}
@@ -144,6 +153,7 @@ impl FlowFilter {
|| given(&self.status)
|| self.under.is_some()
|| self.app.is_some()
|| self.cwd.is_some()
|| self.system.is_some()
|| self.tunnelled.is_some()
|| self.any.as_ref().is_some_and(|groups| groups.iter().any(FlowFilter::narrows))
@@ -224,6 +234,19 @@ impl FlowFilter {
}
sql.push(')');
}
if let Some(cwds) = &self.cwd {
if has_cwd && !cwds.is_empty() {
let marks = vec!["?"; cwds.len()].join(", ");
sql.push_str(&format!(
" AND (client_cwd IN ({marks}) OR lower(folder_name(client_cwd)) IN ({marks}))"
));
params.extend(cwds.iter().map(|c| c.clone().into()));
params.extend(cwds.iter().map(|c| c.to_lowercase().into()));
} else {
// None, or a database from before the column, which has no folders.
sql.push_str(" AND 0");
}
}
match self.system {
Some(true) => sql.push_str(" AND is_system(client_bundle_id, client_path)"),
Some(false) => sql.push_str(" AND NOT is_system(client_bundle_id, client_path)"),
@@ -665,6 +688,142 @@ pub fn clear_mark_in(db: &ProxyQueryManager) -> i64 {
db.with_conn(|c| clear_mark(c.conn().resolve()))
}
/// When captured traffic takes more than `cap` bytes, as [`Stores::used_bytes`] counts them,
/// deletes the flows written first, with their bodies and messages: as many as the average flow
/// says would bring it under, from one to [`DELETE_BATCH`]. Returns the ids deleted, none once it
/// is under or no flow is left, so a caller sweeps a batch per call until then. Cleared flows are
/// left to [`delete_cleared`], and a stream still open is never swept: its writer would go on
/// adding messages to it.
pub fn sweep_oldest(stores: &Stores, cap: u64) -> Result<Vec<String>, String> {
// Bodies a sweep that failed partway left behind go first: they count against the cap.
let pending = pending_sweeps(stores)?;
delete_swept_bodies(stores, &pending)?;
let used = stores.used_bytes()?;
if used <= cap {
return Ok(Vec::new());
}
let count = count(&stores.db)? as u64;
if count == 0 {
return Ok(Vec::new());
}
let average = (used / count).max(1);
let batch = (used - cap).div_ceil(average).clamp(1, DELETE_BATCH as u64);
let rows = sweepable(stores, batch as usize)?;
let deleted = delete_swept_rows(stores, rows)?;
delete_swept_bodies(stores, &deleted)?;
Ok(deleted)
}
/// Flows whose rows a sweep deleted and whose bodies it may not have, at most a batch of them.
pub(crate) fn pending_sweeps(stores: &Stores) -> Result<Vec<String>, String> {
stores
.db
.with_conn(|c| {
let conn = c.conn().resolve();
let mut stmt = conn.prepare("SELECT flow_id FROM flow_sweep_pending LIMIT ?")?;
stmt.query_map([DELETE_BATCH as i64], |r| r.get(0))?.collect::<rusqlite::Result<_>>()
})
.map_err(sweep_failed)
}
/// A stream still open: it has no end yet, and its writer goes on adding messages to it.
const OPEN_STREAM: &str =
"stream_kind IS NOT NULL AND elapsed_ms IS NULL AND error IS NULL AND NOT cancelled";
/// Fail messages for sweeping, all of a kind.
fn sweep_failed(e: impl std::fmt::Display) -> String {
format!("Failed to delete the oldest flows: {e}")
}
/// Up to `limit` of the oldest flows that are neither cleared nor an open stream, by rowid.
pub(crate) fn sweepable(stores: &Stores, limit: usize) -> Result<Vec<(i64, String)>, String> {
stores
.db
.with_conn(|c| {
let conn = c.conn().resolve();
let mut stmt = conn.prepare(&format!(
"SELECT rowid, id FROM flows WHERE rowid > ?
AND NOT ({OPEN_STREAM})
ORDER BY rowid LIMIT ?"
))?;
stmt.query_map(rusqlite::params![clear_mark(conn), limit as i64], |r| {
Ok((r.get(0)?, r.get(1)?))
})?
.collect::<rusqlite::Result<_>>()
})
.map_err(sweep_failed)
}
/// Deletes the flows' rows and their messages' rows in one transaction, first, so nothing
/// written for them after this has an owner left to count under, and records them as pending
/// until their bodies go, so a failure before then is finished by the next sweep. A flow that
/// changed or was cleared since it was chosen is kept. Returns the ids deleted.
pub(crate) fn delete_swept_rows(
stores: &Stores,
rows: Vec<(i64, String)>,
) -> Result<Vec<String>, String> {
stores
.db
.with_tx(|c| {
let conn = c.conn().resolve();
let mut delete = conn.prepare(&format!(
"DELETE FROM flows WHERE rowid = ? AND id = ?
AND NOT ({OPEN_STREAM})"
))?;
let mut delete_messages =
conn.prepare("DELETE FROM flow_messages WHERE flow_id = ?")?;
let mut pending =
conn.prepare("INSERT OR IGNORE INTO flow_sweep_pending (flow_id) VALUES (?)")?;
let mut deleted = Vec::with_capacity(rows.len());
for (rowid, id) in rows {
if delete.execute(rusqlite::params![rowid, id])? > 0 {
delete_messages.execute([&id])?;
pending.execute([&id])?;
deleted.push(id);
}
}
Ok::<_, yaak_database::Error>(deleted)
})
.map_err(sweep_failed)
}
/// Deletes the bodies and message payloads of flows whose rows are gone, then once more the
/// message rows: anything a writer added for them while the rows went is an orphan, which
/// would otherwise go on counting against the cap with nothing to sweep it by. The flows stop
/// being pending once this is done.
pub(crate) fn delete_swept_bodies(stores: &Stores, ids: &[String]) -> Result<(), String> {
if ids.is_empty() {
return Ok(());
}
stores
.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))?;
b.delete_chunks_like(&format!("{id}.msg.%"))?;
}
Ok::<_, yaak_models::error::Error>(())
})
.map_err(|e| sweep_failed(format!("flow bodies: {e}")))?;
stores
.db
.with_tx(|c| {
let conn = c.conn().resolve();
let mut orphans = conn.prepare(
"DELETE FROM flow_messages WHERE flow_id = ?
AND NOT EXISTS (SELECT 1 FROM flows WHERE flows.id = flow_messages.flow_id)",
)?;
let mut done = conn.prepare("DELETE FROM flow_sweep_pending WHERE flow_id = ?")?;
for id in ids {
orphans.execute([id])?;
done.execute([id])?;
}
Ok::<_, yaak_database::Error>(())
})
.map_err(sweep_failed)
}
/// A flow by id, with its number. None once it is cleared, though its row may not be deleted
/// yet.
pub fn find(db: &ProxyQueryManager, flow_id: &str) -> Option<Flow> {
@@ -1014,6 +1173,42 @@ mod tests {
assert_eq!(app(&["com.google.Chrome", "/usr/bin/curl"]), 3);
}
/// A program in a folder: its working directory by the whole path, or its folder's name in
/// any case, along with whatever else the filter asks.
#[test]
fn cwd_matches_a_path_or_a_folder_name() {
let node_in = |cwd: Option<&str>| Flow {
client_name: Some("node".into()),
client_path: Some("/usr/local/bin/node".into()),
client_cwd: cwd.map(String::from),
..flow("https://example.com/", "GET", Some(200), None)
};
let (_dir, db) = db_of(vec![
node_in(Some("/Users/ada/Workspace/fieldwork")),
node_in(Some("/Users/ada/Workspace/fieldwork")),
node_in(Some("/Users/ada/Workspace/Ledger")),
node_in(None),
Flow { client_cwd: Some("/tmp/fieldwork".into()), ..node_in(None) },
Flow { client_path: Some("/usr/bin/curl".into()), ..node_in(Some("/tmp/ledger")) },
]);
let count = |app: Option<&str>, cwds: &[&str]| {
let cwd = Some(cwds.iter().map(|c| c.to_string()).collect());
let app = app.map(|a| vec![a.to_string()]);
urls(&db, FlowFilter { app, cwd, ..Default::default() }).len()
};
assert_eq!(count(None, &["/Users/ada/Workspace/fieldwork"]), 2, "the whole path");
assert_eq!(count(None, &["/users/ada/workspace/fieldwork"]), 0, "a path exactly");
assert_eq!(count(None, &["FieldWork"]), 3, "a folder name, any case");
assert_eq!(count(None, &["fieldwork", "ledger"]), 5, "any of them");
assert_eq!(count(Some("/usr/local/bin/node"), &["ledger"]), 1, "and the program");
assert_eq!(count(Some("/usr/bin/curl"), &["ledger"]), 1);
assert_eq!(count(None, &[]), 0);
let query = FlowFilter { cwd: Some(vec!["a".into(), "/b".into()]), ..Default::default() };
assert_eq!(query.to_query(), "cwd=a&cwd=%2Fb");
assert_eq!(FlowFilter::from_query(&query.to_query()).unwrap(), query);
assert!(query.narrows());
}
#[test]
fn a_search_pages_back_and_counts_what_it_matches() {
// Every third flow is a POST, newest last.
@@ -1460,4 +1655,54 @@ mod tests {
("/wD+", BodyEncoding::Base64, 3)
);
}
/// Sweeping takes the flows written first, with their bodies, until the traffic is under the
/// cap, and no more; under it, nothing goes.
#[test]
fn sweeping_deletes_the_oldest_until_under_the_cap() {
use yaak_database::UpdateSource;
use yaak_models::blob_manager::BodyChunkWriter;
const BODY: usize = 64 * 1024;
let dir = tempfile::tempdir().unwrap();
let stores = Stores::open(dir.path(), &crate::db::test_key()).unwrap();
let empty = stores.used_bytes().unwrap();
let ids: Vec<String> = (0..20).map(|i| format!("fl_{i:02}")).collect();
for id in &ids {
let flow = Flow { id: id.clone(), ..Default::default() };
stores.db.with_tx(|tx| tx.upsert(&flow, &UpdateSource::Background)).unwrap();
let body_id = Flow::body_id(id, FlowBody::Response);
let mut writer = BodyChunkWriter::new(stores.blobs.clone(), body_id);
writer.write(&[7; BODY]).unwrap();
writer.finish().unwrap();
}
let full = stores.used_bytes().unwrap();
assert!(full > empty + (20 * BODY) as u64, "{empty} {full}");
assert_eq!(sweep_oldest(&stores, full).unwrap(), Vec::<String>::new(), "under the cap");
let cap = (empty + full) / 2;
let mut removed = Vec::new();
loop {
let swept = sweep_oldest(&stores, cap).unwrap();
if swept.is_empty() {
break;
}
removed.extend(swept);
}
assert!(stores.used_bytes().unwrap() <= cap);
// About half went, oldest first, and no more than had to.
assert_eq!(removed, ids[..removed.len()]);
assert!((8..=11).contains(&removed.len()), "{removed:?}");
let kept: Vec<String> = list(&stores.db, &FlowFilter::default())
.unwrap()
.into_iter()
.rev()
.map(|f| f.id)
.collect();
assert_eq!(kept, ids[removed.len()..]);
let bodies = stores.blobs.connect();
let has_body = |id: &str| bodies.body_exists(&Flow::body_id(id, FlowBody::Response));
assert!(removed.iter().all(|id| !has_body(id).unwrap()));
assert!(kept.iter().all(|id| has_body(id).unwrap()));
}
}
File diff suppressed because it is too large. Load diff
+30
View File
@@ -59,6 +59,18 @@ pub struct Flow {
pub elapsed_headers_ms: Option<i32>,
/// Milliseconds from `started_at` to the end of the response.
pub elapsed_ms: Option<i32>,
/// Milliseconds from `started_at` to the TCP connection to the server, and to the end of
/// its TLS handshake. Only for intercepted HTTPS on a connection opened for this flow.
pub elapsed_connect_ms: Option<i32>,
pub elapsed_tls_ms: Option<i32>,
/// Milliseconds from `started_at` to the request body being sent whole. None without one.
pub elapsed_request_ms: Option<i32>,
/// The client's end of its connection: `127.0.0.1:54321`.
pub client_addr: Option<String>,
/// The TLS negotiated with the server for intercepted HTTPS: `TLSv1.3`.
pub upstream_tls_version: Option<String>,
/// `TLS13_AES_128_GCM_SHA256`.
pub upstream_tls_cipher: Option<String>,
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.
@@ -244,6 +256,12 @@ impl UpsertModelInfo for Flow {
(GrpcStatus, self.grpc_status.into()),
(GrpcMessage, self.grpc_message.into()),
(Tunnelled, self.tunnelled.into()),
(ElapsedConnectMs, self.elapsed_connect_ms.into()),
(ElapsedTlsMs, self.elapsed_tls_ms.into()),
(ElapsedRequestMs, self.elapsed_request_ms.into()),
(ClientAddr, self.client_addr.into()),
(UpstreamTlsVersion, self.upstream_tls_version.into()),
(UpstreamTlsCipher, self.upstream_tls_cipher.into()),
])
}
@@ -281,6 +299,12 @@ impl UpsertModelInfo for Flow {
FlowIden::GrpcStatus,
FlowIden::GrpcMessage,
FlowIden::Tunnelled,
FlowIden::ElapsedConnectMs,
FlowIden::ElapsedTlsMs,
FlowIden::ElapsedRequestMs,
FlowIden::ClientAddr,
FlowIden::UpstreamTlsVersion,
FlowIden::UpstreamTlsCipher,
]
}
@@ -311,6 +335,12 @@ impl UpsertModelInfo for Flow {
response_truncated: r.get("response_truncated")?,
elapsed_headers_ms: r.get("elapsed_headers_ms")?,
elapsed_ms: r.get("elapsed_ms")?,
elapsed_connect_ms: added_column(r, "elapsed_connect_ms")?,
elapsed_tls_ms: added_column(r, "elapsed_tls_ms")?,
elapsed_request_ms: added_column(r, "elapsed_request_ms")?,
client_addr: added_column(r, "client_addr")?,
upstream_tls_version: added_column(r, "upstream_tls_version")?,
upstream_tls_cipher: added_column(r, "upstream_tls_cipher")?,
error: r.get("error")?,
cancelled: added_column(r, "cancelled")?.unwrap_or_default(),
saved_request_id: added_column(r, "saved_request_id")?,
@@ -15,6 +15,9 @@ pub struct ProxySettings {
pub system_proxy_at_launch: bool,
/// Bytes of each request and response body stored per flow.
pub capture_body_limit: usize,
/// The most captured traffic kept, in bytes of the databases' used pages; the oldest flows
/// are deleted past it. None keeps everything.
pub keep_traffic_bytes: Option<u64>,
/// The HTTP versions offered to clients in intercepted HTTPS.
pub http_version: HttpVersion,
/// The user's decrypt entries, in order: `host:api.example.com`, `app:Google Chrome`, `*`,
@@ -31,6 +34,7 @@ impl Default for ProxySettings {
port: 9090,
system_proxy_at_launch: true,
capture_body_limit: DEFAULT_CAPTURE_BODY_LIMIT,
keep_traffic_bytes: None,
http_version: HttpVersion::Auto,
decrypt: Vec::new(),
decrypt_built_ins_removed: Vec::new(),
@@ -178,6 +182,7 @@ mod tests {
assert_eq!(settings.port, 9090);
assert!(settings.system_proxy_at_launch);
assert_eq!(settings.capture_body_limit, 10 * 1024 * 1024);
assert_eq!(settings.keep_traffic_bytes, None, "everything is kept");
}
#[test]
@@ -197,6 +202,7 @@ mod tests {
port: 8080,
system_proxy_at_launch: false,
capture_body_limit: 1024,
keep_traffic_bytes: Some(1 << 30),
http_version: HttpVersion::Http1Only,
decrypt: vec!["host:api.example.com".into()],
decrypt_built_ins_removed: vec!["!host:*.zoom.us".into()],
@@ -212,6 +218,7 @@ mod tests {
"port": 8080,
"systemProxyAtLaunch": false,
"captureBodyLimit": 1024,
"keepTrafficBytes": 1073741824,
"httpVersion": "http1Only",
"decrypt": ["host:api.example.com"],
"decryptBuiltInsRemoved": ["!host:*.zoom.us"],
@@ -0,0 +1,86 @@
//! Opens a terminal whose shell has the proxy's environment set, so what runs in it goes through
//! the proxy. macOS's Terminal only for now.
/// The script Terminal runs: it deletes itself, exports `vars`, then becomes a fresh interactive
/// login shell, the user's own. Fails on a name a shell could not export.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn script(vars: &[(String, String)]) -> Result<String, String> {
let mut script = String::from("#!/bin/sh\nrm -f \"$0\"\n");
for (name, value) in vars {
let valid = name.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if !valid {
return Err(format!("{name:?} is not a name an environment variable can have"));
}
script.push_str(&format!("export {name}='{}'\n", value.replace('\'', r"'\''")));
}
script.push_str("exec \"${SHELL:-/bin/zsh}\" -l\n");
Ok(script)
}
/// Opens a Terminal window whose shell starts with `vars` set, through a `.command` script in
/// the temporary directory that deletes itself as it runs.
#[cfg(target_os = "macos")]
pub fn open(vars: &[(String, String)]) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let script = script(vars)?;
let name = format!("yaak-proxy-{}.command", yaak_database::generate_prefixed_id("sh"));
let path = std::env::temp_dir().join(name);
let fail = |e: std::io::Error| format!("Failed to write {}: {e}", path.display());
std::fs::write(&path, script).map_err(fail)?;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).map_err(fail)?;
let output = std::process::Command::new("/usr/bin/open")
.args(["-a", "Terminal"])
.arg(&path)
.output()
.map_err(|e| format!("Failed to run open: {e}"))?;
if output.status.success() {
return Ok(());
}
let _ = std::fs::remove_file(&path);
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if stderr.is_empty() { format!("open exited with {}", output.status) } else { stderr })
}
#[cfg(not(target_os = "macos"))]
pub fn open(_vars: &[(String, String)]) -> Result<(), String> {
Err("Opening a terminal with the proxy set is only supported on macOS so far".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_script_exports_each_variable_quoted_then_starts_a_login_shell() {
let vars = vec![
("HTTPS_PROXY".to_string(), "http://127.0.0.1:9090".to_string()),
("NOTE".to_string(), "it's $HOME".to_string()),
];
assert_eq!(
script(&vars).unwrap(),
"#!/bin/sh\nrm -f \"$0\"\nexport HTTPS_PROXY='http://127.0.0.1:9090'\n\
export NOTE='it'\\''s $HOME'\nexec \"${SHELL:-/bin/zsh}\" -l\n"
);
for name in ["", "1A", "A B", "A;rm"] {
assert!(script(&[(name.to_string(), "x".to_string())]).is_err(), "{name:?}");
}
}
#[test]
fn the_script_runs_as_a_shell_sets_them() {
let vars = vec![("YAAK_TEST".to_string(), "a 'b' $c".to_string())];
// Without deleting itself, as `$0` is no script here, or starting a shell.
let script = script(&vars)
.unwrap()
.replace("rm -f \"$0\"\n", "")
.replace("exec \"${SHELL:-/bin/zsh}\" -l\n", "");
let output = std::process::Command::new("sh")
.args(["-c", &format!("{script}printf %s \"$YAAK_TEST\"")])
.output();
// Only where there is a POSIX shell.
if let Ok(output) = output {
assert_eq!(String::from_utf8_lossy(&output.stdout), "a 'b' $c");
}
}
}
@@ -336,6 +336,8 @@ async fn body_rpcs() {
"recording": true,
"systemProxyAtLaunch": false,
"httpVersion": "auto",
"captureBodyLimit": CAPTURE_LIMIT,
"keepTrafficBytes": null,
"startError": null,
"encrypting": null,
"locked": null,
@@ -7,6 +7,7 @@
"dialog:allow-save",
"opener:allow-default-urls",
"opener:allow-open-url",
"opener:allow-reveal-item-in-dir",
"os:allow-os-type",
"core:window:allow-close",
"core:window:allow-is-fullscreen",
+17
View File
@@ -177,4 +177,21 @@ mod tests {
);
assert_eq!(target("../yaak-app-client/vendored/protoc/include"), Some(PROTOC_INCLUDE_DIR));
}
/// The window may call each opener command the app's settings use: without its permission,
/// Tauri refuses the call and the button does nothing.
#[test]
fn the_window_may_open_links_and_reveal_folders() {
let capability: serde_json::Value =
serde_json::from_str(include_str!("../capabilities/default.json")).unwrap();
let permissions: Vec<&str> = capability["permissions"]
.as_array()
.unwrap()
.iter()
.filter_map(|p| p.as_str())
.collect();
for needed in ["opener:allow-open-url", "opener:allow-reveal-item-in-dir"] {
assert!(permissions.contains(&needed), "{needed} missing from {permissions:?}");
}
}
}
+12 -5
View File
@@ -56,6 +56,8 @@ pub(crate) struct CaptureBody {
id: u64,
start: Instant,
engine: Arc<Engine>,
/// The capture limit as the body started, so a change applies to bodies after it.
limit: usize,
captured: usize,
size: u64,
finished: bool,
@@ -77,6 +79,7 @@ impl CaptureBody {
direction,
id,
start,
limit: engine.capture_body_limit(),
engine,
captured: 0,
size: 0,
@@ -122,16 +125,20 @@ impl CaptureBody {
self.emit_messages(messages);
}
let truncated = self.size > self.captured as u64;
let elapsed_ms = self.start.elapsed().as_millis() as u64;
let event = match self.direction {
Direction::Request if self.size == 0 => return,
Direction::Request => {
ProxyEvent::RequestBodyComplete { id: self.id, size: self.size, truncated }
}
Direction::Request => ProxyEvent::RequestBodyComplete {
id: self.id,
size: self.size,
truncated,
elapsed_ms,
},
Direction::Response => ProxyEvent::ResponseBodyComplete {
id: self.id,
size: self.size,
truncated,
elapsed_ms: self.start.elapsed().as_millis() as u64,
elapsed_ms,
error,
cancelled,
},
@@ -153,7 +160,7 @@ impl Body for CaptureBody {
Poll::Ready(Some(Ok(frame))) => {
if let Some(frame_data) = frame.data_ref() {
let len = frame_data.len();
let room = self.engine.capture_body_limit.saturating_sub(self.captured);
let room = self.limit.saturating_sub(self.captured);
let data = frame_data.slice(..len.min(room));
self.captured += data.len();
self.size += len as u64;
+28 -3
View File
@@ -16,7 +16,7 @@ use std::collections::hash_map::RandomState;
use std::collections::{HashMap, HashSet};
use std::hash::{BuildHasher, Hasher};
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc as std_mpsc;
use std::sync::{Arc, Mutex, RwLock};
use std::time::SystemTime;
@@ -139,6 +139,21 @@ pub enum ProxyEvent {
size: u64,
/// Whether `size` exceeded `capture_body_limit`.
truncated: bool,
/// Milliseconds from `started_at` to the body's end.
elapsed_ms: u64,
},
/// The connection to the server an intercepted HTTPS request goes on, before it is sent.
/// Plain HTTP shares pooled connections the engine does not see opened, so it has none.
UpstreamConnection {
id: u64,
/// Milliseconds from `started_at` to the TCP connection, and to the TLS handshake's
/// end. None when the connection was opened for an earlier request and reused.
elapsed_connect_ms: Option<u64>,
elapsed_tls_ms: Option<u64>,
/// `TLSv1.3`, `TLSv1.2`.
tls_version: Option<String>,
/// `TLS13_AES_128_GCM_SHA256`.
tls_cipher: Option<String>,
},
/// Response headers arrived from upstream.
ResponseStart {
@@ -250,6 +265,11 @@ impl ProxyHandle {
self.engine.http2.store(enabled, Ordering::Relaxed);
}
/// Bytes of each body captured, for bodies that start from now on.
pub fn set_capture_body_limit(&self, limit: usize) {
self.engine.capture_body_limit.store(limit, Ordering::Relaxed);
}
/// Decides which HTTPS connections are decrypted from the next CONNECT on. Connections
/// already open stay decrypted or tunnelled as they were.
pub fn set_decrypt_policy(&self, policy: DecryptPolicy) {
@@ -285,7 +305,7 @@ pub(crate) struct ClientConn {
pub(crate) struct Engine {
ca: Arc<CaStore>,
capture_body_limit: usize,
capture_body_limit: AtomicUsize,
events: std_mpsc::Sender<ProxyEvent>,
next_id: AtomicU64,
next_connection: AtomicU64,
@@ -309,6 +329,11 @@ impl Engine {
let _ = self.events.send(event);
}
/// Bytes of each body captured, as it is when the body starts.
fn capture_body_limit(&self) -> usize {
self.capture_body_limit.load(Ordering::Relaxed)
}
fn next_flow_id(&self) -> u64 {
self.next_id.fetch_add(1, Ordering::Relaxed)
}
@@ -408,7 +433,7 @@ pub fn start_proxy(config: ProxyConfig) -> Result<ProxyHandle, String> {
let (event_tx, event_rx) = std_mpsc::channel();
let engine = Arc::new(Engine {
ca: config.ca,
capture_body_limit: config.capture_body_limit,
capture_body_limit: AtomicUsize::new(config.capture_body_limit),
events: event_tx,
next_id: AtomicU64::new(1),
next_connection: AtomicU64::new(1),
+51 -11
View File
@@ -150,7 +150,7 @@ impl Flow {
fn capture_request(&self, body: Incoming) -> BoxBody {
let parser = self.grpc.as_ref().map(|call| {
let limit = self.engine.capture_body_limit;
let limit = self.engine.capture_body_limit();
let encoding = call.encoding.as_deref();
let parser = GrpcParser::new(MessageDirection::Client, encoding, call.text, limit)
.with_content_encoding(call.content_encoding.as_deref());
@@ -165,7 +165,7 @@ impl Flow {
/// How the response's body is read for messages, if it streams them, after recording that
/// it does.
fn response_parser(&self, resp: &Response<Incoming>) -> Option<BodyParser> {
let limit = self.engine.capture_body_limit;
let limit = self.engine.capture_body_limit();
let headers = resp.headers();
if self.grpc.is_some() {
// A call that fails at once answers with its status in the headers, and no body.
@@ -189,6 +189,21 @@ impl Flow {
Some(BodyParser::Sse(Box::new(SseParser::new(encoding, limit))))
}
/// Records the connection the request goes on: its timings when it was opened for this
/// request, and its TLS either way.
fn goes_on(&self, conn: &UpstreamConn, reused: bool) {
let since = |at: Instant| {
(!reused).then(|| at.saturating_duration_since(self.start).as_millis() as u64)
};
self.engine.emit(ProxyEvent::UpstreamConnection {
id: self.id,
elapsed_connect_ms: since(conn.tls.connected),
elapsed_tls_ms: since(conn.tls.handshaken),
tls_version: conn.tls.version.clone(),
tls_cipher: conn.tls.cipher.clone(),
});
}
fn respond_head(&mut self, resp: &Response<Incoming>, remote_addr: Option<SocketAddr>) {
self.answered = true;
self.engine.emit(ProxyEvent::ResponseStart {
@@ -576,9 +591,36 @@ enum Upstream {
struct UpstreamConn {
sender: Upstream,
remote_addr: Option<SocketAddr>,
tls: TlsInfo,
}
/// When a connection to the server was made, and the TLS it negotiated.
#[derive(Debug, Clone)]
struct TlsInfo {
connected: Instant,
handshaken: Instant,
version: Option<String>,
cipher: Option<String>,
}
impl TlsInfo {
fn of(conn: &rustls::ClientConnection, connected: Instant) -> Self {
let version = conn.protocol_version().map(|v| match v {
rustls::ProtocolVersion::TLSv1_3 => "TLSv1.3".to_string(),
rustls::ProtocolVersion::TLSv1_2 => "TLSv1.2".to_string(),
other => format!("{other:?}"),
});
let cipher = conn.negotiated_cipher_suite().map(|s| format!("{:?}", s.suite()));
Self { connected, handshaken: Instant::now(), version, cipher }
}
}
impl UpstreamConn {
/// Shared by another request, as an HTTP/2 connection is.
fn share(&self, sender: Upstream) -> Self {
Self { sender, remote_addr: self.remote_addr, tls: self.tls.clone() }
}
fn is_h2(&self) -> bool {
matches!(self.sender, Upstream::H2(_))
}
@@ -636,10 +678,7 @@ impl Upstreams {
conns.retain(|c| !c.is_closed());
if let Some(i) = conns.iter().position(UpstreamConn::is_ready) {
let conn = match &conns[i].sender {
Upstream::H2(s) => UpstreamConn {
sender: Upstream::H2(s.clone()),
remote_addr: conns[i].remote_addr,
},
Upstream::H2(s) => conns[i].share(Upstream::H2(s.clone())),
Upstream::H1(_) => conns.swap_remove(i),
};
return Ok((conn, true));
@@ -650,10 +689,7 @@ impl Upstreams {
}
let conn = connect_upstream(engine, &self.target, false).await?;
match &conn.sender {
Upstream::H2(s) => conns.push(UpstreamConn {
sender: Upstream::H2(s.clone()),
remote_addr: conn.remote_addr,
}),
Upstream::H2(s) => conns.push(conn.share(Upstream::H2(s.clone()))),
Upstream::H1(_) => self.h1.store(true, Ordering::Relaxed),
}
Ok((conn, false))
@@ -682,10 +718,12 @@ async fn connect_upstream(
h1_only: bool,
) -> Result<UpstreamConn, BoxError> {
let tcp = TcpStream::connect(&target.addr).await?;
let connected = Instant::now();
let remote_addr = tcp.peer_addr().ok();
let config = if h1_only { &engine.upstream_tls_h1 } else { &engine.upstream_tls };
let server_name = ServerName::try_from(target.server_name.clone())?;
let tls = TlsConnector::from(config.clone()).connect(server_name, tcp).await?;
let tls_info = TlsInfo::of(tls.get_ref().1, connected);
let is_h2 = tls.get_ref().1.alpn_protocol() == Some(b"h2");
let io = TokioIo::new(tls);
@@ -711,7 +749,7 @@ async fn connect_upstream(
});
Upstream::H1(sender)
};
Ok(UpstreamConn { sender: upstream, remote_addr })
Ok(UpstreamConn { sender: upstream, remote_addr, tls: tls_info })
}
/// Addresses a request to the server in the protocol of `conn`. It may be addressed for the
@@ -790,6 +828,7 @@ async fn forward_https(
Ok(taken) => taken,
Err(e) => return Ok(flow.fail(error_chain(e.as_ref()))),
};
flow.goes_on(&conn, reused);
let (mut parts, body) = req.into_parts();
if let Err(e) = address(&mut parts, &conn, target, &path, !body.is_end_stream()) {
@@ -813,6 +852,7 @@ async fn forward_https(
Err(e) => return Ok(flow.fail(error_chain(e.as_ref()))),
};
reused = false;
flow.goes_on(&conn, reused);
let (mut parts, body) = unsent.into_parts();
if let Err(e) = address(&mut parts, &conn, target, &path, !body.is_end_stream()) {
return Ok(flow.fail(e));
+1 -1
View File
@@ -120,7 +120,7 @@ pub(crate) async fn relay(
};
let (client_read, client_write) = tokio::io::split(TokioIo::new(client));
let (upstream_read, upstream_write) = tokio::io::split(TokioIo::new(upstream));
let limit = flow.engine.capture_body_limit;
let limit = flow.engine.capture_body_limit();
let outbound = FrameParser::new(
MessageDirection::Client,
flow.clone(),
+24
View File
@@ -415,6 +415,7 @@ fn flow_id(event: &ProxyEvent) -> u64 {
| ProxyEvent::RequestHeader { id, .. }
| ProxyEvent::RequestBodyChunk { id, .. }
| ProxyEvent::RequestBodyComplete { id, .. }
| ProxyEvent::UpstreamConnection { id, .. }
| ProxyEvent::ResponseStart { id, .. }
| ProxyEvent::ResponseHeader { id, .. }
| ProxyEvent::ResponseBodyChunk { id, .. }
@@ -1710,6 +1711,29 @@ async fn h2_client_to_an_h2_upstream() {
(*size, error.clone(), *cancelled));
assert_eq!((size, error, cancelled), (text.len() as u64, None, false));
assert_eq!(body(&events, false).0, text);
// The connection opened for it, timed from the flow's start, and its TLS.
let (connect, tls, version, cipher) = find!(events,
ProxyEvent::UpstreamConnection { elapsed_connect_ms, elapsed_tls_ms, tls_version, tls_cipher, .. } =>
(*elapsed_connect_ms, *elapsed_tls_ms, tls_version.clone(), tls_cipher.clone()));
let headers_at = find!(events, ProxyEvent::ResponseStart { elapsed_ms, .. } => *elapsed_ms);
assert!(connect.unwrap() <= tls.unwrap() && tls.unwrap() <= headers_at);
assert_eq!(version.as_deref(), Some("TLSv1.3"));
assert!(cipher.unwrap().starts_with("TLS13_"));
// The next request shares it: no connect or handshake of its own, the same TLS.
let resp = client.send_request(h2_request("POST", port, "/echo", full("again"))).await;
resp.unwrap().into_body().collect().await.unwrap();
let events = h.flow_events();
let (connect, tls, version) = find!(events,
ProxyEvent::UpstreamConnection { elapsed_connect_ms, elapsed_tls_ms, tls_version, .. } =>
(*elapsed_connect_ms, *elapsed_tls_ms, tls_version.clone()));
assert_eq!((connect, tls, version.as_deref()), (None, None, Some("TLSv1.3")));
let (sent_at, end_at) = (
find!(events, ProxyEvent::RequestBodyComplete { elapsed_ms, .. } => *elapsed_ms),
find!(events, ProxyEvent::ResponseBodyComplete { elapsed_ms, .. } => *elapsed_ms),
);
assert!(sent_at <= end_at, "{sent_at} {end_at}");
}
#[tokio::test(flavor = "multi_thread")]
@@ -116,7 +116,7 @@ export function AutoScroller<T>({
className={classNames(
"absolute bottom-3 left-1/2 -translate-x-1/2 z-10",
"flex items-center gap-1.5 h-6 px-3 rounded-full",
"bg-surface border border-border shadow-lg text-text text-xs",
"bg-surface border border-border shadow-lg text-text text-xs tabular-nums",
"hover:bg-surface-highlight",
)}
>
@@ -106,6 +106,18 @@ export interface HttpRequestPaneViewProps {
/** Tabs only the app has, after the shared ones */
extraTabs?: ExtraPaneTab[];
/** Replaces the URL bar, as the proxy's card shows a captured request's method and URL */
urlLine?: ReactNode;
/** Tabs with nothing in them read in the subtlest colour */
dimEmptyTabs?: boolean;
/**
* The tab to open on for a request with no tab of its own; with it, a tab chosen for one
* request doesn't carry to the next
*/
defaultTab?: string;
/** A tab was chosen */
onTabChange?: (tab: string) => void;
}
type HeadersEditorInherited = { name: string; value: string; enabled?: boolean; id?: string }[];
@@ -153,6 +165,10 @@ export function HttpRequestPaneView({
authTabs,
authContent,
extraTabs,
urlLine,
dimEmptyTabs = false,
defaultTab,
onTabChange,
}: HttpRequestPaneViewProps) {
const { ErrorBoundary } = useEditorHost();
const readOnly = !editable;
@@ -168,27 +184,27 @@ export function HttpRequestPaneView({
: 0;
}
const headerCount = request.headers.filter((h) => h.name && h.enabled !== false).length;
const tabs = useMemo<TabItem[]>(
() => [
bodyTab ?? {
value: REQUEST_TAB_BODY,
label: bodyTypeLabel(request.bodyType, request.contentType),
empty: dimEmptyTabs && request.bodyType == null,
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
},
{
value: REQUEST_TAB_PARAMS,
rightSlot: <CountBadge count={paramPairs.length} />,
label: "Params",
empty: dimEmptyTabs && paramPairs.length === 0,
},
...(headersTabs ?? [
{
value: REQUEST_TAB_HEADERS,
label: "Headers",
rightSlot: (
<CountBadge
count={request.headers.filter((h) => h.name && h.enabled !== false).length}
/>
),
empty: dimEmptyTabs && headerCount === 0,
rightSlot: <CountBadge count={headerCount} />,
},
]),
...(authTabs ??
@@ -205,14 +221,15 @@ export function HttpRequestPaneView({
[
authTabs,
bodyTab,
dimEmptyTabs,
extraTabs,
headerCount,
headersTabs,
numParams,
paramPairs.length,
request.auth,
request.bodyType,
request.contentType,
request.headers,
],
);
@@ -235,6 +252,7 @@ export function HttpRequestPaneView({
style={style}
className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1")}
>
{urlLine ?? (
<UrlBar
stateKey={`url.${request.id}`}
key={urlBarKey}
@@ -264,6 +282,7 @@ export function HttpRequestPaneView({
forceUpdateKey={urlForceUpdateKey ?? forceUpdateKey}
isLoading={isSending}
/>
)}
<Tabs
ref={tabsRef}
label="Request"
@@ -271,6 +290,9 @@ export function HttpRequestPaneView({
tabListClassName="mt-1 -mb-1.5"
storageKey={tabsStorageKey}
activeTabKey={request.id}
defaultValue={defaultTab}
keyedDefault={defaultTab != null}
onChangeValue={onTabChange}
>
<TabContent value={REQUEST_TAB_AUTH} ErrorBoundary={ErrorBoundary}>
{authContent ?? (request.auth != null && <AuthDisplay auth={request.auth} />)}
@@ -69,6 +69,22 @@ export interface HttpResponsePaneViewProps {
infoRows?: ReactElement[];
/** The copy button for a body held back for its size */
renderCopyButton?: (getText: () => Promise<string | null>) => ReactNode;
/**
* The request is shown apart, as the proxy's card shows it beside the response: no Request
* tab, and Headers and Cookies hold the response's alone
*/
requestShownApart?: boolean;
/** Drawn inside a card the app draws, without a card of its own */
bare?: boolean;
/** Tabs with nothing in them read in the subtlest colour */
dimEmptyTabs?: boolean;
/**
* The tab to open on for a key with no tab of its own; with it, a tab chosen under one key
* doesn't carry to the next
*/
defaultTab?: string;
/** A tab was chosen */
onTabChange?: (tab: string) => void;
}
/** Yaak's response pane: a status line over Response, Request, Headers and Cookies tabs */
@@ -95,6 +111,11 @@ export function HttpResponsePaneView({
renderBodyViewer,
infoRows,
renderCopyButton,
requestShownApart = false,
bare = false,
dimEmptyTabs = false,
defaultTab,
onTabChange,
}: HttpResponsePaneViewProps) {
const { ErrorBoundary } = useEditorHost();
const mimeType =
@@ -138,9 +159,15 @@ export function HttpResponsePaneView({
: []),
];
const noBody = response == null || (response.contentLength ?? 0) === 0;
const headerCount = response?.headers.length ?? 0;
const cookieCount = requestShownApart
? cookieCounts.received
: cookieCounts.sent + cookieCounts.received;
return [
{
value: RESPONSE_TAB_BODY,
empty: dimEmptyTabs && noBody,
options: {
value: viewMode,
onChange: (v) => onViewModeChange(v === "raw" ? "raw" : "pretty"),
@@ -153,40 +180,48 @@ export function HttpResponsePaneView({
itemsAfter: itemsAfter.length > 0 ? itemsAfter : undefined,
},
},
{
value: RESPONSE_TAB_REQUEST,
label: "Request",
rightSlot: (response?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
},
...(requestShownApart
? []
: [
{
value: RESPONSE_TAB_REQUEST,
label: "Request",
rightSlot:
(response?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
},
]),
{
value: RESPONSE_TAB_HEADERS,
label: "Headers",
rightSlot: (
<CountBadge
count={response?.requestHeaders.length ?? 0}
count2={response?.headers.length ?? 0}
showZero
/>
empty: dimEmptyTabs && headerCount === 0,
rightSlot: requestShownApart ? (
<CountBadge count={headerCount} />
) : (
<CountBadge count={response?.requestHeaders.length ?? 0} count2={headerCount} showZero />
),
},
{
value: RESPONSE_TAB_COOKIES,
label: "Cookies",
rightSlot:
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
) : null,
empty: dimEmptyTabs && cookieCount === 0,
rightSlot: requestShownApart ? (
<CountBadge count={cookieCounts.received} />
) : cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
) : null,
},
...(extraTabs?.map((t) => t.tab) ?? []),
];
}, [
cookieCounts,
dimEmptyTabs,
extraTabs,
mimeType,
onCopyAsCurl,
onCopyBody,
onSaveBody,
onViewModeChange,
requestShownApart,
response,
viewMode,
]);
@@ -197,9 +232,8 @@ export function HttpResponsePaneView({
className={classNames(
className,
"x-theme-responsePane",
"max-h-full h-full",
"bg-surface rounded-md border border-border-subtle overflow-hidden",
"relative",
"max-h-full h-full overflow-hidden relative",
!bare && "bg-surface rounded-md border border-border-subtle",
)}
>
{response == null ? (
@@ -234,6 +268,9 @@ export function HttpResponsePaneView({
tabListClassName="mt-0.5 -mb-1.5"
storageKey={tabsStorageKey}
activeTabKey={activeTabKey}
defaultValue={defaultTab}
keyedDefault={defaultTab != null}
onChangeValue={onTabChange}
>
<TabContent value={RESPONSE_TAB_BODY} ErrorBoundary={ErrorBoundary}>
<ErrorBoundary name="Http Response Viewer">
@@ -259,6 +296,7 @@ export function HttpResponsePaneView({
</Suspense>
</ErrorBoundary>
</TabContent>
{!requestShownApart && (
<TabContent value={RESPONSE_TAB_REQUEST} ErrorBoundary={ErrorBoundary}>
<ConfirmLargeResponse
contentLength={response.requestContentLength}
@@ -277,11 +315,19 @@ export function HttpResponsePaneView({
)}
</ConfirmLargeResponse>
</TabContent>
)}
<TabContent value={RESPONSE_TAB_HEADERS} ErrorBoundary={ErrorBoundary}>
<ResponseHeaders response={response} infoRows={infoRows} />
<ResponseHeaders
response={response}
infoRows={infoRows}
responseOnly={requestShownApart}
/>
</TabContent>
<TabContent value={RESPONSE_TAB_COOKIES} ErrorBoundary={ErrorBoundary}>
<ResponseCookies response={response} />
<ResponseCookies
response={response}
side={requestShownApart ? "received" : undefined}
/>
</TabContent>
{extraTabs?.map(({ tab, content }) => (
<TabContent key={tab.value} value={tab.value} ErrorBoundary={ErrorBoundary}>
@@ -10,6 +10,8 @@ import type { HttpResponseView } from "./types";
interface Props {
response: HttpResponseView;
/** Only the cookies sent or only those received, for a pane that shows one side */
side?: "sent" | "received";
}
interface ParsedCookie {
@@ -102,7 +104,7 @@ export function parseCookieHeaders({ sent, received }: HttpResponseView["cookieH
};
}
export function ResponseCookies({ response }: Props) {
export function ResponseCookies({ response, side }: Props) {
const { useDetailsBannerStoredValue } = useEditorHost();
const { sentCookies, receivedCookies } = useMemo(
() => parseCookieHeaders(response.cookieHeaders),
@@ -111,6 +113,7 @@ export function ResponseCookies({ response }: Props) {
return (
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
{side !== "received" && (
<DetailsBanner
defaultOpen
useStoredValue={useDetailsBannerStoredValue}
@@ -134,7 +137,9 @@ export function ResponseCookies({ response }: Props) {
</KeyValueRows>
)}
</DetailsBanner>
)}
{side !== "sent" && (
<DetailsBanner
defaultOpen
useStoredValue={useDetailsBannerStoredValue}
@@ -213,6 +218,7 @@ export function ResponseCookies({ response }: Props) {
</div>
)}
</DetailsBanner>
)}
</div>
);
}
@@ -2,6 +2,7 @@ import { format, formatDistanceToNowStrict } from "date-fns";
import type { ReactElement } from "react";
import { DetailsBanner } from "../DetailsBanner";
import { useEditorHost } from "../EditorHost";
import { EmptyStateText } from "../EmptyStateText";
import { HeadersTable } from "../HeadersTable";
import { IconButton } from "../IconButton";
import type { KeyValueRowProps } from "../KeyValueRow";
@@ -12,10 +13,30 @@ interface Props {
response: HttpResponseView;
/** More rows for the Info section, after the standard ones, each keyed */
infoRows?: ReactElement[];
/** Only the response's headers, for a pane whose request and info are shown elsewhere */
responseOnly?: boolean;
}
export function ResponseHeaders({ response, infoRows }: Props) {
export function ResponseHeaders({ response, infoRows, responseOnly }: Props) {
const { openUrl, useDetailsBannerStoredValue } = useEditorHost();
if (responseOnly) {
return (
<div className="overflow-auto h-full pb-4 pr-0.5">
{response.headers.length === 0 ? (
<EmptyStateText>No Headers</EmptyStateText>
) : (
<KeyValueRows>
{response.headers.map((h, i) => (
// oxlint-disable-next-line react/no-array-index-key
<KeyValueRow labelColor="info" key={i} label={h.name}>
{h.value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</div>
);
}
return (
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
<DetailsBanner
@@ -13,7 +13,12 @@ import {
SettingsList,
SettingsSection,
} from "./SettingRow";
import type { SettingsCallbacks, SettingsEditorKeymap, SettingsView } from "./types";
import type {
SettingsCallbacks,
SettingsEditorKeymap,
SettingsInterfaceRow,
SettingsView,
} from "./types";
const NULL_FONT_VALUE = "__NULL_FONT__";
@@ -44,6 +49,7 @@ export function SettingsInterface({
trailingSections,
}: SettingsInterfaceProps) {
const { app, values, fonts } = view;
const shown = (row: SettingsInterfaceRow) => !view.hiddenInterfaceRows?.includes(row);
return (
<VStack space={1.5} className="mb-4">
@@ -125,43 +131,55 @@ export function SettingsInterface({
</SettingRow>
</SettingsSection>
<SettingsSection title="Editor">
<SettingRowSelect
name="editorKeymap"
title="Editor keymap"
description="Keyboard shortcut preset used by text editors."
value={values.editorKeymap}
onChange={(editorKeymap) => callbacks.onChange({ editorKeymap })}
options={keymaps}
/>
<SettingRowBoolean
title="Wrap editor lines"
description="Wrap long lines in request and response editors."
checked={values.editorSoftWrap}
onChange={(editorSoftWrap) => callbacks.onChange({ editorSoftWrap })}
/>
<SettingRowBoolean
title="Colorize request methods"
description="Use method-specific colors for HTTP request methods."
checked={values.coloredMethods}
onChange={(coloredMethods) => callbacks.onChange({ coloredMethods })}
/>
</SettingsSection>
{(shown("editorKeymap") || shown("editorSoftWrap") || shown("coloredMethods")) && (
<SettingsSection title="Editor">
{shown("editorKeymap") && (
<SettingRowSelect
name="editorKeymap"
title="Editor keymap"
description="Keyboard shortcut preset used by text editors."
value={values.editorKeymap}
onChange={(editorKeymap) => callbacks.onChange({ editorKeymap })}
options={keymaps}
/>
)}
{shown("editorSoftWrap") && (
<SettingRowBoolean
title="Wrap editor lines"
description="Wrap long lines in request and response editors."
checked={values.editorSoftWrap}
onChange={(editorSoftWrap) => callbacks.onChange({ editorSoftWrap })}
/>
)}
{shown("coloredMethods") && (
<SettingRowBoolean
title="Colorize request methods"
description="Use method-specific colors for HTTP request methods."
checked={values.coloredMethods}
onChange={(coloredMethods) => callbacks.onChange({ coloredMethods })}
/>
)}
</SettingsSection>
)}
<SettingsSection title="Window">
<NativeTitlebarSetting
useNativeTitlebar={values.useNativeTitlebar}
onApply={callbacks.onApplyNativeTitlebar}
/>
{view.windowControlsSetting && (
<SettingRowBoolean
title="Hide window controls"
description="Hide the close, maximize, and minimize controls on Windows or Linux."
checked={values.hideWindowControls}
onChange={(hideWindowControls) => callbacks.onChange({ hideWindowControls })}
/>
)}
</SettingsSection>
{(shown("nativeTitlebar") || view.windowControlsSetting) && (
<SettingsSection title="Window">
{shown("nativeTitlebar") && (
<NativeTitlebarSetting
useNativeTitlebar={values.useNativeTitlebar}
onApply={callbacks.onApplyNativeTitlebar}
/>
)}
{view.windowControlsSetting && (
<SettingRowBoolean
title="Hide window controls"
description="Hide the close, maximize, and minimize controls on Windows or Linux."
checked={values.hideWindowControls}
onChange={(hideWindowControls) => callbacks.onChange({ hideWindowControls })}
/>
)}
</SettingsSection>
)}
{trailingSections}
</SettingsList>
@@ -106,8 +106,17 @@ export interface SettingsView {
feedbackPrompts: boolean;
/** Null when this build has no license */
license: SettingsLicenseView | null;
/** Interface rows this app doesn't act on yet, left out of the tab */
hiddenInterfaceRows?: SettingsInterfaceRow[];
}
/** The Interface tab's rows an app may leave out */
export type SettingsInterfaceRow =
| "editorKeymap"
| "editorSoftWrap"
| "coloredMethods"
| "nativeTitlebar";
export interface SettingsCallbacks {
onChange: (patch: SettingsPatch) => void | Promise<void>;
/** Save the native title bar choice and restart, since it only applies at launch */
+27 -4
View File
@@ -41,6 +41,8 @@ export type TabItem =
value: string;
label: string;
hidden?: boolean;
/** Nothing in it: the label reads in the subtlest colour */
empty?: boolean;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
}
@@ -57,6 +59,8 @@ export type TabItem =
export interface TabOptionsItem {
value: string;
options: Omit<RadioDropdownProps, "children">;
/** Nothing in it: the label reads in the subtlest colour */
empty?: boolean;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
}
@@ -89,6 +93,11 @@ export interface TabsProps {
activeTabKey?: string;
/** Where the order and active tab under `storageKey` are kept */
useStoredValue?: UseStoredValue;
/**
* A key with no stored tab opens on `defaultValue`, not on the tab last chosen under another
* key, and a stored tab no longer offered falls back the same way
*/
keyedDefault?: boolean;
}
export const Tabs = forwardRef<TabsRef, TabsProps>(function Tabs(
@@ -105,6 +114,7 @@ export const Tabs = forwardRef<TabsRef, TabsProps>(function Tabs(
storageKey,
activeTabKey,
useStoredValue: useStoredValueProp,
keyedDefault = false,
}: TabsProps,
forwardedRef: Ref<TabsRef>,
) {
@@ -131,8 +141,12 @@ export const Tabs = forwardRef<TabsRef, TabsProps>(function Tabs(
// Get the active tab value - prefer storage (if activeTabKey), then defaultValue, then first tab
const storedActiveTab = activeTabKey ? storage?.activeTabs?.[activeTabKey] : undefined;
const [internalValue, setInternalValue] = useState<string | undefined>(undefined);
const value = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value;
const [internal, setInternal] = useState<{ key?: string; value: string } | undefined>(undefined);
const internalValue =
keyedDefault && internal?.key !== activeTabKey ? undefined : internal?.value;
const offered = (v: string | undefined) =>
v != null && (!keyedDefault || originalTabs.some((t) => t.value === v));
const value = [storedActiveTab, internalValue, defaultValue].find(offered) ?? originalTabs[0]?.value;
// Helper to normalize storage (handle migration from old format)
const normalizeStorage = useCallback(
@@ -144,7 +158,7 @@ export const Tabs = forwardRef<TabsRef, TabsProps>(function Tabs(
// Handle tab change - update internal state, storage if we have a key, and call prop callback
const onChangeValue = useCallback(
async (newValue: string) => {
setInternalValue(newValue);
setInternal({ key: activeTabKey, value: newValue });
if (storageKey && activeTabKey) {
await setStorage((s) => {
const normalized = normalizeStorage(s);
@@ -501,7 +515,15 @@ function TabButton({
) : "options" in tab ? (
<TabOptionsButton tab={tab} buttonProps={btnProps} isActive={isActive} />
) : (
<Button leftSlot={tab.leftSlot} rightSlot={tab.rightSlot} {...btnProps}>
<Button
leftSlot={tab.leftSlot}
rightSlot={tab.rightSlot}
{...btnProps}
className={classNames(
btnProps.className,
!isActive && "empty" in tab && tab.empty && "text-text-subtlest!",
)}
>
{"label" in tab && tab.label ? tab.label : tab.value}
</Button>
);
@@ -574,6 +596,7 @@ function TabOptionsButton({
</div>
}
{...buttonProps}
className={classNames(buttonProps.className, !isActive && tab.empty && "text-text-subtlest!")}
>
{option && "shortLabel" in option && option.shortLabel
? option.shortLabel