mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Detail card, settings window and small fixes for Yaak Proxy (#824)
This commit is contained in:
1 parent
4538123d88
commit
2ca2bc5f00
75 files changed
+4574
-1147
No files matched your search
@@ -59,3 +59,6 @@ flatpak/node-sources.json
|
|||||||
# Claude Code local settings
|
# Claude Code local settings
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
|
|
||||||
|
# Headless screenshots taken while working
|
||||||
|
.shots/
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Prec } from "@codemirror/state";
|
import { Prec } from "@codemirror/state";
|
||||||
import { type EditorView, keymap } from "@codemirror/view";
|
import { type EditorView, keymap } from "@codemirror/view";
|
||||||
import type { DecryptListEntry, SeenTargets } from "@yaakapp-internal/proxy-lib";
|
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 { filterAutocomplete } from "@yaakapp-internal/ui/editor";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { atom, useAtom } from "jotai";
|
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
|
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
|
||||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
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
|
* 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 `!`,
|
* 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
|
* then the hosts and apps seen in the capture. Saves as it changes. Placed by Settings' Proxy
|
||||||
* that exists, by the dialog the state pill's More… opens.
|
* tab.
|
||||||
*/
|
*/
|
||||||
export function DecryptListEditor({ className }: { className?: string }) {
|
export function DecryptListEditor({ className }: { className?: string }) {
|
||||||
const list = useDecryptList();
|
const list = useDecryptList();
|
||||||
@@ -167,21 +166,3 @@ function EntryInput({
|
|||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 classNames from "classnames";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { selectedFlowAtom } from "../lib/atoms";
|
import { selectedFlowAtom } from "../lib/atoms";
|
||||||
import { findHeader, mimeEssence } from "../lib/body";
|
import { EmptyState } from "./FlowBodyViewer";
|
||||||
import { saveDialogFlowAtom, useFlowActions } from "../lib/flowActions";
|
import { FlowCard } from "./FlowCard";
|
||||||
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 { NotDecrypted } from "./NotDecrypted";
|
import { NotDecrypted } from "./NotDecrypted";
|
||||||
|
|
||||||
const TAB_RESPONSE = "response";
|
/**
|
||||||
const TAB_REQUEST = "request";
|
* The detail pane: the selected flow's card, or what the pane holds before one is selected. Side
|
||||||
const TAB_HEADERS = "headers";
|
* by side, a wider gutter separates the card from the list and it sits a little under the header.
|
||||||
const TAB_QUERY = "query";
|
*/
|
||||||
const TAB_MESSAGES = "messages";
|
|
||||||
|
|
||||||
export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideBySide: boolean }) {
|
export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideBySide: boolean }) {
|
||||||
const flow = useAtomValue(selectedFlowAtom);
|
const flow = useAtomValue(selectedFlowAtom);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={style}
|
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
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
@@ -66,274 +38,13 @@ export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideB
|
|||||||
) : flow.tunnelled ? (
|
) : flow.tunnelled ? (
|
||||||
<NotDecrypted flow={flow} />
|
<NotDecrypted flow={flow} />
|
||||||
) : (
|
) : (
|
||||||
<FlowPane flow={flow} />
|
<FlowCard flow={flow} listBeside={sideBySide} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>•</span>
|
|
||||||
<span
|
|
||||||
title={
|
|
||||||
flow.elapsedHeadersMs == null
|
|
||||||
? undefined
|
|
||||||
: `HEADERS: ${formatMillis(flow.elapsedHeadersMs)}\nTOTAL: ${formatMillis(flow.elapsedMs)}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{formatMillis(flow.elapsedMs)}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!noResponse && (
|
|
||||||
<>
|
|
||||||
<span>•</span>
|
|
||||||
<SizeTag contentLength={flow.responseSize} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
{flow.responseTruncated ? (
|
|
||||||
<span
|
|
||||||
className={classNames(
|
|
||||||
"justify-self-end flex items-center gap-1.5 h-5 px-2.5 ml-3 rounded-full",
|
|
||||||
"border border-notice/50 text-notice font-sans text-xs",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon icon="scissors" size="xs" />
|
|
||||||
{responseBody.data == null
|
|
||||||
? "Body cut"
|
|
||||||
: `First ${formatSize(responseBody.data.storedSize)} kept`}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
<HStack space={1} className="font-sans ml-3">
|
|
||||||
<Button
|
|
||||||
size="2xs"
|
|
||||||
variant="border"
|
|
||||||
color={flow.savedRequestId == null ? "default" : "primary"}
|
|
||||||
title={flow.savedRequestId == null ? undefined : "Saved to Yaak. Click to save again."}
|
|
||||||
leftSlot={<Icon icon="bookmark" size="xs" />}
|
|
||||||
onClick={() => openSave(flow)}
|
|
||||||
>
|
|
||||||
{flow.savedRequestId == null ? "Save to Yaak" : "Saved"}
|
|
||||||
</Button>
|
|
||||||
<Dropdown items={flowActions(flow).slice(2)}>
|
|
||||||
<IconButton size="xs" icon="ellipsis" iconColor="secondary" title="Copy and export" />
|
|
||||||
</Dropdown>
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-hidden flex flex-col min-h-0">
|
|
||||||
{flow.error != null && (
|
|
||||||
<Banner color="danger" className="mx-3 mt-1 shrink-0 font-mono text-sm">
|
|
||||||
{flow.error}
|
|
||||||
</Banner>
|
|
||||||
)}
|
|
||||||
{flow.cancelled && (
|
|
||||||
<Banner color="secondary" className="mx-3 mt-1 shrink-0 text-sm">
|
|
||||||
{flow.status == null
|
|
||||||
? "The app cancelled the request before a response came back."
|
|
||||||
: "The app cancelled the request before the response finished."}
|
|
||||||
</Banner>
|
|
||||||
)}
|
|
||||||
<Tabs
|
|
||||||
// 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[] }) {
|
function Keys({ keys }: { keys: string[] }) {
|
||||||
return (
|
return (
|
||||||
<span className="flex gap-1 justify-end">
|
<span className="flex gap-1 justify-end">
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -329,7 +329,7 @@ export function FlowList({ style, className }: Props) {
|
|||||||
className={classNames(
|
className={classNames(
|
||||||
"absolute bottom-3 left-1/2 -translate-x-1/2",
|
"absolute bottom-3 left-1/2 -translate-x-1/2",
|
||||||
"flex items-center gap-1.5 h-6 px-3 rounded-full",
|
"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",
|
"hover:bg-surface-highlight",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -107,11 +107,8 @@ export const FlowListHeader = memo(function FlowListHeader({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSort(c.id)}
|
onClick={() => onSort(c.id)}
|
||||||
title={
|
// No tooltip: the header sits under the search field, and a tooltip here covered it
|
||||||
sort?.column === c.id
|
aria-label={`Sort by ${c.label}`}
|
||||||
? "Sorted over the loaded flows. Sort by Start Time in this menu goes back."
|
|
||||||
: `Sort by ${c.label}`
|
|
||||||
}
|
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"flex items-center gap-0.5 min-w-0 w-full h-full whitespace-nowrap hover:text-text-subtle",
|
"flex items-center gap-0.5 min-w-0 w-full h-full whitespace-nowrap hover:text-text-subtle",
|
||||||
c.align === "right" && "justify-end",
|
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>,
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,12 +25,22 @@ export function LockedState({ lock }: { lock: ProxyLock }) {
|
|||||||
) : (
|
) : (
|
||||||
<Icon icon="lock" size="lg" className="text-text-subtle mb-1" />
|
<Icon icon="lock" size="lg" className="text-text-subtle mb-1" />
|
||||||
)}
|
)}
|
||||||
<div className="text-lg font-semibold">
|
<div className="text-lg font-semibold whitespace-nowrap">
|
||||||
{encrypting != null ? `Encrypting stored traffic, ${encrypting}` : "Traffic is locked"}
|
{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>
|
||||||
<div className="text-sm text-text-subtle">
|
<div className="text-sm text-text-subtle">
|
||||||
{encrypting != null
|
{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())}
|
: lockedLine(lock, getOsType())}
|
||||||
</div>
|
</div>
|
||||||
{encrypting == null && (
|
{encrypting == null && (
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import {
|
|||||||
Toasts,
|
Toasts,
|
||||||
} from "@yaakapp-internal/ui";
|
} from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useLocalStorage } from "react-use";
|
import { useLocalStorage } from "react-use";
|
||||||
import { flowLayoutAtom, settingsRequestedAtom } from "../lib/atoms";
|
import { flowLayoutAtom } from "../lib/atoms";
|
||||||
import { setupIncomplete } from "../lib/firstRun";
|
import { setupIncomplete } from "../lib/firstRun";
|
||||||
import { lockedAtom, noFlowsAtom, systemProxyEverOnAtom } from "../lib/store";
|
import { lockedAtom, noFlowsAtom, systemProxyEverOnAtom } from "../lib/store";
|
||||||
import { getOsType } from "../lib/tauri";
|
import { getOsType } from "../lib/tauri";
|
||||||
@@ -20,17 +20,17 @@ import { FlowDetail } from "./FlowDetail";
|
|||||||
import { FlowList } from "./FlowList";
|
import { FlowList } from "./FlowList";
|
||||||
import { LockedState } from "./LockedState";
|
import { LockedState } from "./LockedState";
|
||||||
import { ProxyStatus, useProxySetup } from "./ProxyStatus";
|
import { ProxyStatus, useProxySetup } from "./ProxyStatus";
|
||||||
import { DecryptListDialog } from "./DecryptListEditor";
|
|
||||||
import { HelperDialog } from "./HelperDialog";
|
import { HelperDialog } from "./HelperDialog";
|
||||||
import { SaveDialog } from "./SaveDialog";
|
import { SaveDialog } from "./SaveDialog";
|
||||||
import { SearchBar } from "./SearchBar";
|
import { SearchBar } from "./SearchBar";
|
||||||
|
import { SettingsWindow, useOpenSettings } from "./Settings";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { TakeOverBanner } from "./TakeOverBanner";
|
import { TakeOverBanner } from "./TakeOverBanner";
|
||||||
|
|
||||||
export function ProxyLayout() {
|
export function ProxyLayout() {
|
||||||
const os = getOsType();
|
const os = getOsType();
|
||||||
const [flowLayout, setFlowLayout] = useAtom(flowLayoutAtom);
|
const [flowLayout, setFlowLayout] = useAtom(flowLayoutAtom);
|
||||||
const requestSettings = useSetAtom(settingsRequestedAtom);
|
const openSettings = useOpenSettings();
|
||||||
const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar_width", 250);
|
const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar_width", 250);
|
||||||
const [sidebarHidden, setSidebarHidden] = useLocalStorage("sidebar_hidden", false);
|
const [sidebarHidden, setSidebarHidden] = useLocalStorage("sidebar_hidden", false);
|
||||||
const [floatingSidebarHidden, setFloatingSidebarHidden] = useLocalStorage(
|
const [floatingSidebarHidden, setFloatingSidebarHidden] = useLocalStorage(
|
||||||
@@ -57,10 +57,16 @@ export function ProxyLayout() {
|
|||||||
className="x-theme-appHeader bg-surface"
|
className="x-theme-appHeader bg-surface"
|
||||||
>
|
>
|
||||||
{/* Yaak's silhouette: the sidebar toggle and the state pill where Yaak has the toggle and
|
{/* 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. */}
|
the environment, the search where it names the request, then its icons. Only the
|
||||||
<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">
|
controls take the pointer: everything around them, the traffic lights' corner
|
||||||
<div className="flex items-center gap-1 pl-1 min-w-0 pointer-events-auto">
|
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
|
<IconButton
|
||||||
|
className="pointer-events-auto"
|
||||||
size="sm"
|
size="sm"
|
||||||
title="Toggle sidebar"
|
title="Toggle sidebar"
|
||||||
icon={isHidden ? "left_panel_hidden" : "left_panel_visible"}
|
icon={isHidden ? "left_panel_hidden" : "left_panel_visible"}
|
||||||
@@ -75,8 +81,10 @@ export function ProxyLayout() {
|
|||||||
/>
|
/>
|
||||||
<ProxyStatus />
|
<ProxyStatus />
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-auto min-w-0">
|
<div data-tauri-drag-region className="min-w-0">
|
||||||
<SearchBar />
|
<div className="pointer-events-auto">
|
||||||
|
<SearchBar />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div data-tauri-drag-region className="flex items-center justify-end gap-1 pr-1">
|
<div data-tauri-drag-region className="flex items-center justify-end gap-1 pr-1">
|
||||||
<ClearFlowsButton />
|
<ClearFlowsButton />
|
||||||
@@ -96,7 +104,7 @@ export function ProxyLayout() {
|
|||||||
title="Settings"
|
title="Settings"
|
||||||
size="sm"
|
size="sm"
|
||||||
iconColor="secondary"
|
iconColor="secondary"
|
||||||
onClick={() => requestSettings((n) => n + 1)}
|
onClick={() => openSettings()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,7 +159,7 @@ export function ProxyLayout() {
|
|||||||
</SidebarLayout>
|
</SidebarLayout>
|
||||||
<SaveDialog />
|
<SaveDialog />
|
||||||
<HelperDialog />
|
<HelperDialog />
|
||||||
<DecryptListDialog />
|
<SettingsWindow />
|
||||||
<Toasts />
|
<Toasts />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,23 +10,17 @@ import {
|
|||||||
LoadingIcon,
|
LoadingIcon,
|
||||||
} from "@yaakapp-internal/ui";
|
} from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import type { HTMLAttributes, ReactNode, Ref } from "react";
|
import type { HTMLAttributes, ReactNode, Ref } from "react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useRpcEvent } from "../hooks/useRpcEvent";
|
import { useRpcEvent } from "../hooks/useRpcEvent";
|
||||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||||
import { helperDialogOpenAtom, helperRegisteredAtAtom } from "../lib/atoms";
|
import { helperRegisteredAtAtom } from "../lib/atoms";
|
||||||
import { decryptListDialogOpenAtom } from "./DecryptListEditor";
|
|
||||||
import { encryptingPercent } from "../lib/encrypting";
|
import { encryptingPercent } from "../lib/encrypting";
|
||||||
import { environmentExports } from "../lib/env";
|
import { environmentExports } from "../lib/env";
|
||||||
import { proxyStateAtom, refreshProxyState, systemProxyEverOnAtom } from "../lib/store";
|
import { proxyStateAtom, systemProxyEverOnAtom } from "../lib/store";
|
||||||
import {
|
import { helperPollInterval, recordingLabel, systemProxySwitch } from "../lib/systemProxy";
|
||||||
helperPollInterval,
|
|
||||||
helperRow,
|
|
||||||
recordingLabel,
|
|
||||||
systemProxySwitch,
|
|
||||||
} from "../lib/systemProxy";
|
|
||||||
import { onWindowFocus } from "../lib/tauri";
|
import { onWindowFocus } from "../lib/tauri";
|
||||||
import { useShowToast } from "../lib/toast";
|
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,
|
* 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
|
* 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
|
* 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() {
|
export function ProxyStatus() {
|
||||||
const dropdownRef = useRef<DropdownRef>(null);
|
const dropdownRef = useRef<DropdownRef>(null);
|
||||||
@@ -42,11 +36,6 @@ export function ProxyStatus() {
|
|||||||
const setup = useProxySetup();
|
const setup = useProxySetup();
|
||||||
const execute = useRpcMutation("execute_action");
|
const execute = useRpcMutation("execute_action");
|
||||||
const retry = useRpcMutation("take_over");
|
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 { caStatus, trust, systemProxy, setSystemProxy } = setup;
|
||||||
const systemSwitch = useSystemProxySwitch(setup);
|
const systemSwitch = useSystemProxySwitch(setup);
|
||||||
|
|
||||||
@@ -113,72 +102,6 @@ export function ProxyStatus() {
|
|||||||
systemProxy.data.helper !== "enabled" &&
|
systemProxy.data.helper !== "enabled" &&
|
||||||
systemProxy.data.helper !== "unavailable";
|
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[] = [
|
const items: DropdownItem[] = [
|
||||||
{ type: "content", label: stateRow },
|
{ type: "content", label: stateRow },
|
||||||
{
|
{
|
||||||
@@ -204,7 +127,7 @@ export function ProxyStatus() {
|
|||||||
</span>
|
</span>
|
||||||
{helperNotice && (
|
{helperNotice && (
|
||||||
<span className="block text-notice">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -262,8 +185,6 @@ export function ProxyStatus() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ type: "separator" },
|
|
||||||
{ label: "More…", icon: "settings", submenu: moreItems },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
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 */
|
/** 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() {
|
export function useCopyEnvironment() {
|
||||||
const proxyState = useAtomValue(proxyStateAtom);
|
const proxyState = useAtomValue(proxyStateAtom);
|
||||||
const caStatus = useRpcQuery("ca_status", {});
|
const caStatus = useRpcQuery("ca_status", {});
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import {
|
|||||||
searchTermsAtom,
|
searchTermsAtom,
|
||||||
searchTextAtom,
|
searchTextAtom,
|
||||||
searchTextVersionAtom,
|
searchTextVersionAtom,
|
||||||
searchWindowAtom,
|
|
||||||
setSearchTextAtom,
|
setSearchTextAtom,
|
||||||
shownTotalAtom,
|
|
||||||
} from "../lib/store";
|
} from "../lib/store";
|
||||||
|
|
||||||
/** How long typing settles before the search runs */
|
/** 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,
|
* 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;
|
* 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.
|
* ⌘F focuses it and Escape clears it.
|
||||||
*/
|
*/
|
||||||
export const SearchBar = memo(function SearchBar() {
|
export const SearchBar = memo(function SearchBar() {
|
||||||
const [text, setText] = useAtom(searchTextAtom);
|
const [text, setText] = useAtom(searchTextAtom);
|
||||||
@@ -75,26 +73,24 @@ export const SearchBar = memo(function SearchBar() {
|
|||||||
}}
|
}}
|
||||||
leftSlot={
|
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} />
|
<PlaceholderCount inputRef={inputRef} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
rightSlot={
|
rightSlot={
|
||||||
text === "" ? (
|
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
|
⌘F
|
||||||
</kbd>
|
</kbd>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-1 self-center">
|
<IconButton
|
||||||
<MatchCount />
|
size="xs"
|
||||||
<IconButton
|
icon="x"
|
||||||
size="xs"
|
title="Clear search"
|
||||||
icon="x"
|
iconColor="secondary"
|
||||||
title="Clear search"
|
className="self-center ml-2 mr-0.5"
|
||||||
iconColor="secondary"
|
onClick={clear}
|
||||||
onClick={clear}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -118,16 +114,3 @@ function placeholderFor(total: number): string {
|
|||||||
if (total === 0) return "Search flows";
|
if (total === 0) return "Search flows";
|
||||||
return `Search ${total.toLocaleString()} ${total === 1 ? "flow" : "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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ function ItemInner({ item }: { item: SidebarItem }) {
|
|||||||
<div className="flex items-center gap-2 w-full min-w-0">
|
<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 === "all" && <Icon icon="layers" size="sm" className="text-text-subtle" />}
|
||||||
{item.kind === "tunnelled" && <Icon icon="lock" 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} />}
|
{item.appPath != null && <AppIcon path={item.appPath} />}
|
||||||
<span className="truncate">{item.label}</span>
|
<span className="truncate">{item.label}</span>
|
||||||
{item.sublabel != null && (
|
{item.sublabel != null && (
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
// Yaak's default interface size, so the two apps read as one family. Later from the shared
|
import { applyFontSizes } from "./lib/interfaceSettings";
|
||||||
// Interface settings.
|
import { readSettingsValues, SETTINGS_VALUES_KEY } from "./lib/settings";
|
||||||
document.documentElement.style.fontSize = "15px";
|
|
||||||
|
// 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)));
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,17 @@
|
|||||||
import {
|
import { useSetAtom } from "jotai";
|
||||||
HttpRequestPaneView,
|
import { useLayoutEffect, useState } from "react";
|
||||||
HttpResponsePaneView,
|
import { FlowDetail } from "../components/FlowDetail";
|
||||||
type HttpBodyResult,
|
import { FlowList } from "../components/FlowList";
|
||||||
type ResponseViewMode,
|
import { selectedFlowAtom } from "../lib/atoms";
|
||||||
} from "@yaakapp-internal/ui";
|
import { browserTabMemory, learnedTabKey, flowShape } from "../lib/defaultTab";
|
||||||
import { useMemo, useState } from "react";
|
import { flowWindowAtom } from "../lib/store";
|
||||||
import { ProxyEditorHost } from "../components/ProxyEditorHost";
|
|
||||||
import { findHeader } from "../lib/body";
|
|
||||||
import {
|
|
||||||
type FlowBodyData,
|
|
||||||
flowBodyFromRpc,
|
|
||||||
flowBodyLoader,
|
|
||||||
flowRequestToView,
|
|
||||||
flowResponseToView,
|
|
||||||
} from "../lib/flowViews";
|
|
||||||
import { type FlowFixture, fixtures } from "./fixtures";
|
import { type FlowFixture, fixtures } from "./fixtures";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dev only: renders captured flows through the shared request and response panes, read-only,
|
* Dev only: captured flows in the detail card, over fixtures. Open
|
||||||
* side by side, the way the proxy's detail view will. Open `/harness.html?flow=0&theme=dark`.
|
* `/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() {
|
export function FlowPanesHarness() {
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
@@ -27,87 +20,57 @@ export function FlowPanesHarness() {
|
|||||||
if (fixture == null) return null;
|
if (fixture == null) return null;
|
||||||
|
|
||||||
return (
|
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="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] gap-2 p-2 bg-surface">
|
<div className="flex gap-2 text-sm">
|
||||||
<div className="flex gap-2 text-sm">
|
{fixtures.map((f, i) => (
|
||||||
{fixtures.map((f, i) => (
|
<button
|
||||||
<button
|
key={f.flow.id}
|
||||||
key={f.flow.id}
|
type="button"
|
||||||
type="button"
|
className={i === index ? "text-text" : "text-text-subtle"}
|
||||||
className={i === index ? "text-text" : "text-text-subtle"}
|
onClick={() => setIndex(i)}
|
||||||
onClick={() => setIndex(i)}
|
>
|
||||||
>
|
{f.label}
|
||||||
{f.label}
|
</button>
|
||||||
</button>
|
))}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<FlowPanes key={fixture.flow.id} fixture={fixture} />
|
|
||||||
</div>
|
</div>
|
||||||
</ProxyEditorHost>
|
<FixtureCard key={fixture.flow.id} fixture={fixture} />
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loaded(data: FlowBodyData): HttpBodyResult<FlowBodyData | null> {
|
function FixtureCard({ fixture }: { fixture: FlowFixture }) {
|
||||||
return { data, isLoading: false, error: null };
|
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 */
|
if (params.has("list")) {
|
||||||
async function formatLocally(text: string, language: unknown): Promise<string> {
|
return (
|
||||||
if (language !== "json") return text;
|
<div className="h-full min-h-0 grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||||
try {
|
<FlowList className="min-h-0" />
|
||||||
return JSON.stringify(JSON.parse(text), null, 2);
|
<FlowDetail sideBySide />
|
||||||
} catch {
|
</div>
|
||||||
return text;
|
);
|
||||||
}
|
}
|
||||||
|
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} />;
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { GetProxyStateResponse } from "@yaakapp-internal/proxy-lib";
|
|||||||
import { useStore } from "jotai";
|
import { useStore } from "jotai";
|
||||||
import { type ReactNode, useEffect, useState } from "react";
|
import { type ReactNode, useEffect, useState } from "react";
|
||||||
import { ProxyLayout } from "../components/ProxyLayout";
|
import { ProxyLayout } from "../components/ProxyLayout";
|
||||||
|
import { DragRegionCheck } from "./DragRegionCheck";
|
||||||
import { useHotkeyContextValue } from "../lib/hotkeys";
|
import { useHotkeyContextValue } from "../lib/hotkeys";
|
||||||
import { syncFlows, syncProxyState, syncSidebarSelection } from "../lib/store";
|
import { syncFlows, syncProxyState, syncSidebarSelection } from "../lib/store";
|
||||||
|
|
||||||
@@ -21,6 +22,8 @@ const STATE: GetProxyStateResponse = {
|
|||||||
encrypting: null,
|
encrypting: null,
|
||||||
locked: { message: LOCKED, keychain: true },
|
locked: { message: LOCKED, keychain: true },
|
||||||
otherOwner: null,
|
otherOwner: null,
|
||||||
|
captureBodyLimit: 10 * 1024 * 1024,
|
||||||
|
keepTrafficBytes: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** What the backend answers while the keychain refuses: the state, and every read refused */
|
/** 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;
|
if (cmd.startsWith("plugin:event|")) return 0;
|
||||||
throw new Error(`Not in the harness: ${cmd}`);
|
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",
|
os_type: "macos",
|
||||||
platform: "macos",
|
platform: "macos",
|
||||||
family: "unix",
|
family: "unix",
|
||||||
@@ -67,7 +71,10 @@ export function installLockedBackend() {
|
|||||||
localStorage.setItem("system_proxy_ever_on", "true");
|
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() {
|
export function LockedHarness() {
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const [synced, setSynced] = useState(false);
|
const [synced, setSynced] = useState(false);
|
||||||
@@ -81,6 +88,7 @@ export function LockedHarness() {
|
|||||||
return (
|
return (
|
||||||
<Hotkeys>
|
<Hotkeys>
|
||||||
<ProxyLayout />
|
<ProxyLayout />
|
||||||
|
{new URLSearchParams(location.search).has("dragcheck") && <DragRegionCheck />}
|
||||||
</Hotkeys>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,12 @@ const base: Flow = {
|
|||||||
grpcStatus: null,
|
grpcStatus: null,
|
||||||
grpcMessage: null,
|
grpcMessage: null,
|
||||||
tunnelled: false,
|
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 });
|
const jsonRequest = JSON.stringify({ amount: 4200, currency: "usd", capture: true });
|
||||||
@@ -147,4 +153,26 @@ export const fixtures: FlowFixture[] = [
|
|||||||
requestBody: body(""),
|
requestBody: body(""),
|
||||||
responseBody: 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(""),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
@@ -3,22 +3,27 @@ import { applyThemeToDocument, defaultDarkTheme, defaultLightTheme } from "@yaak
|
|||||||
import { createStore, Provider } from "jotai";
|
import { createStore, Provider } from "jotai";
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { installFixtureBackend } from "./backend";
|
||||||
import { FlowPanesHarness } from "./FlowPanesHarness";
|
import { FlowPanesHarness } from "./FlowPanesHarness";
|
||||||
import { installLockedBackend, LockedHarness } from "./LockedHarness";
|
import { installLockedBackend, LockedHarness } from "./LockedHarness";
|
||||||
import { MessagesHarness } from "./MessagesHarness";
|
import { MessagesHarness } from "./MessagesHarness";
|
||||||
|
import { SettingsHarness } from "./SettingsHarness";
|
||||||
import "../main.css";
|
import "../main.css";
|
||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const dark = params.get("theme") !== "light";
|
const dark = params.get("theme") !== "light";
|
||||||
applyThemeToDocument(dark ? defaultDarkTheme : defaultLightTheme);
|
applyThemeToDocument(dark ? defaultDarkTheme : defaultLightTheme);
|
||||||
if (params.has("locked")) installLockedBackend();
|
if (params.has("locked")) installLockedBackend();
|
||||||
|
else installFixtureBackend();
|
||||||
|
|
||||||
createRoot(document.getElementById("root") as HTMLElement).render(
|
createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
<Provider store={createStore()}>
|
<Provider store={createStore()}>
|
||||||
{params.has("locked") ? (
|
{params.has("locked") || params.has("window") ? (
|
||||||
<LockedHarness />
|
<LockedHarness />
|
||||||
|
) : params.has("settings") ? (
|
||||||
|
<SettingsHarness />
|
||||||
) : params.has("stream") ? (
|
) : params.has("stream") ? (
|
||||||
<MessagesHarness />
|
<MessagesHarness />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ const flowBase: Flow = {
|
|||||||
grpcStatus: null,
|
grpcStatus: null,
|
||||||
grpcMessage: null,
|
grpcMessage: null,
|
||||||
tunnelled: false,
|
tunnelled: false,
|
||||||
|
clientAddr: null,
|
||||||
|
elapsedConnectMs: null,
|
||||||
|
elapsedTlsMs: null,
|
||||||
|
elapsedRequestMs: null,
|
||||||
|
upstreamTlsVersion: null,
|
||||||
|
upstreamTlsCipher: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Seconds past 21:41:12 as a stored timestamp */
|
/** Seconds past 21:41:12 as a stored timestamp */
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ export const followingAtom = atom(true);
|
|||||||
/** When the helper was last registered, so its state is read until approval shows */
|
/** When the helper was last registered, so its state is read until approval shows */
|
||||||
export const helperRegisteredAtAtom = atom<number | null>(null);
|
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 */
|
/** The dialog that walks through allowing the helper in System Settings */
|
||||||
export const helperDialogOpenAtom = atom(false);
|
export const helperDialogOpenAtom = atom(false);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export function flowBodyQueryOptions(flow: Flow, which: FlowBody) {
|
|||||||
queryFn: async () => viewerBody(await rpc("get_flow_body", { flowId: flow.id, which })),
|
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.
|
// Reading a stored body fails the same way every time, so the error shows at once.
|
||||||
retry: false,
|
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 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,75 @@
|
|||||||
import { describe, expect, test } from "vite-plus/test";
|
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", () => {
|
describe("environmentExports", () => {
|
||||||
test("points the proxy variables at the port and the CA variables at the certificate", () => {
|
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, MAC_PATH)).toBe(
|
||||||
expect(environmentExports(9090, path)).toBe(
|
|
||||||
[
|
[
|
||||||
"export HTTP_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 HTTPS_PROXY=http://127.0.0.1:9090",
|
||||||
"export http_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 https_proxy=http://127.0.0.1:9090",
|
||||||
`export SSL_CERT_FILE='${path}'`,
|
`export SSL_CERT_FILE='${MAC_PATH}'`,
|
||||||
`export NODE_EXTRA_CA_CERTS='${path}'`,
|
`export NODE_EXTRA_CA_CERTS='${MAC_PATH}'`,
|
||||||
`export REQUESTS_CA_BUNDLE='${path}'`,
|
`export REQUESTS_CA_BUNDLE='${MAC_PATH}'`,
|
||||||
].join("\n"),
|
].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
@@ -1,21 +1,70 @@
|
|||||||
import { shellQuote } from "./curl";
|
import { shellQuote } from "./curl";
|
||||||
|
|
||||||
/**
|
/** The shells the terminal environment is written for */
|
||||||
* Shell exports that send one terminal's traffic through the proxy on `port` and trust its CA at
|
export type TerminalShell = "posix" | "fish" | "powershell" | "cmd";
|
||||||
* `caPath`, for tools that read neither the system proxy nor the login keychain: Node, Python,
|
|
||||||
* and anything built on OpenSSL.
|
export const TERMINAL_SHELLS: { value: TerminalShell; label: string }[] = [
|
||||||
*/
|
{ value: "posix", label: "bash / zsh" },
|
||||||
export function environmentExports(port: number, caPath: string): string {
|
{ value: "fish", label: "fish" },
|
||||||
const proxy = `http://127.0.0.1:${port}`;
|
{ value: "powershell", label: "PowerShell" },
|
||||||
const ca = shellQuote(caPath);
|
{ value: "cmd", label: "cmd" },
|
||||||
return [
|
];
|
||||||
`export HTTP_PROXY=${proxy}`,
|
|
||||||
`export HTTPS_PROXY=${proxy}`,
|
/** The shell a platform's terminal opens by default */
|
||||||
// curl reads only the lowercase name for plain HTTP.
|
export function defaultShell(osType: string | null | undefined): TerminalShell {
|
||||||
`export http_proxy=${proxy}`,
|
return osType === "windows" ? "powershell" : "posix";
|
||||||
`export https_proxy=${proxy}`,
|
}
|
||||||
`export SSL_CERT_FILE=${ca}`,
|
|
||||||
`export NODE_EXTRA_CA_CERTS=${ca}`,
|
/**
|
||||||
`export REQUESTS_CA_BUNDLE=${ca}`,
|
* The variables that send one terminal's traffic through the proxy on `port` and trust its CA
|
||||||
].join("\n");
|
* 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);
|
||||||
}
|
}
|
||||||
@@ -59,6 +59,12 @@ const flow: Flow = {
|
|||||||
grpcStatus: null,
|
grpcStatus: null,
|
||||||
grpcMessage: null,
|
grpcMessage: null,
|
||||||
tunnelled: false,
|
tunnelled: false,
|
||||||
|
clientAddr: null,
|
||||||
|
elapsedConnectMs: null,
|
||||||
|
elapsedTlsMs: null,
|
||||||
|
elapsedRequestMs: null,
|
||||||
|
upstreamTlsVersion: null,
|
||||||
|
upstreamTlsCipher: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function bytes(text: string): FlowBodyData {
|
function bytes(text: string): FlowBodyData {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
type HttpResponseView,
|
type HttpResponseView,
|
||||||
} from "@yaakapp-internal/ui/httpTypes";
|
} from "@yaakapp-internal/ui/httpTypes";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { base64ToBytes, bytesToText, findHeader, mimeEssence } from "./body";
|
import { base64ToBytes, bodyAsText, findHeader, mimeEssence } from "./body";
|
||||||
import { statusReason } from "./statusReason";
|
import { statusReason } from "./statusReason";
|
||||||
|
|
||||||
/** A captured body as the engine's body RPC returns it, decoded */
|
/** 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 */
|
/** A captured request as the shared request pane shows it, read-only */
|
||||||
export function flowRequestToView(flow: Flow, body: FlowBodyData | null): HttpRequestView {
|
export function flowRequestToView(flow: Flow, body: FlowBodyData | null): HttpRequestView {
|
||||||
const contentType = findHeader(flow.requestHeaders, "content-type");
|
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);
|
const { bodyType, body: bodyView } = requestBodyView(contentType, body, text);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -84,7 +85,18 @@ export function flowResponseToView(
|
|||||||
): HttpResponseView | null {
|
): HttpResponseView | null {
|
||||||
const ended = flow.elapsedMs != null || flow.error != null || flow.cancelled;
|
const ended = flow.elapsedMs != null || flow.error != null || flow.cancelled;
|
||||||
if (flow.status == null && !ended) return null;
|
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 encoding = findHeader(flow.responseHeaders, "content-encoding")?.trim().toLowerCase();
|
||||||
const encoded = encoding != null && encoding !== "" && encoding !== "identity";
|
const encoded = encoding != null && encoding !== "" && encoding !== "identity";
|
||||||
|
|
||||||
@@ -133,7 +145,10 @@ export function flowBodyLoader({
|
|||||||
return {
|
return {
|
||||||
useText: () => {
|
useText: () => {
|
||||||
const body = useBody();
|
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: () => {
|
useBytes: () => {
|
||||||
const body = useBody();
|
const body = useBody();
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) }));
|
||||||
|
}
|
||||||
@@ -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
|
* 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.
|
* 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 `-` */
|
/** A word of the search box: a free term, or `@field:value`, maybe negated with `-` */
|
||||||
export interface SearchToken {
|
export interface SearchToken {
|
||||||
@@ -75,12 +78,14 @@ export function parseSearch(text: string, hideSystem = false): FlowQuery | null
|
|||||||
function parseClause(tokens: SearchToken[], hideSystem: boolean): FlowQuery | null {
|
function parseClause(tokens: SearchToken[], hideSystem: boolean): FlowQuery | null {
|
||||||
const words: string[] = [];
|
const words: string[] = [];
|
||||||
const apps: string[] = [];
|
const apps: string[] = [];
|
||||||
|
const cwds: string[] = [];
|
||||||
// Each host with the paths written after it.
|
// Each host with the paths written after it.
|
||||||
const hosts: { host: string; paths: string[] }[] = [];
|
const hosts: { host: string; paths: string[] }[] = [];
|
||||||
let system: boolean | undefined = hideSystem ? false : undefined;
|
let system: boolean | undefined = hideSystem ? false : undefined;
|
||||||
let tunnelled: boolean | undefined;
|
let tunnelled: boolean | undefined;
|
||||||
for (const t of tokens) {
|
for (const t of tokens) {
|
||||||
if (t.field === "app" && !t.negated && t.value !== "") apps.push(t.value);
|
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 !== "") {
|
else if (t.field === "host" && !t.negated && t.value !== "") {
|
||||||
hosts.push({ host: t.value.replace(/\/+$/, ""), paths: [] });
|
hosts.push({ host: t.value.replace(/\/+$/, ""), paths: [] });
|
||||||
} else if (t.field === "path" && !t.negated && t.value !== "") {
|
} else if (t.field === "path" && !t.negated && t.value !== "") {
|
||||||
@@ -107,6 +112,7 @@ function parseClause(tokens: SearchToken[], hideSystem: boolean): FlowQuery | nu
|
|||||||
...(q === "" ? {} : { q }),
|
...(q === "" ? {} : { q }),
|
||||||
...(under.length > 0 ? { under } : {}),
|
...(under.length > 0 ? { under } : {}),
|
||||||
...(apps.length > 0 ? { app: apps } : {}),
|
...(apps.length > 0 ? { app: apps } : {}),
|
||||||
|
...(cwds.length > 0 ? { cwd: cwds } : {}),
|
||||||
...(system == null ? {} : { system }),
|
...(system == null ? {} : { system }),
|
||||||
...(tunnelled == null ? {} : { tunnelled }),
|
...(tunnelled == null ? {} : { tunnelled }),
|
||||||
};
|
};
|
||||||
@@ -136,19 +142,49 @@ export function widerQueries(text: string): string[] {
|
|||||||
return [...out];
|
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 {
|
export function folderOf(flow: Pick<Flow, "clientBundleId" | "clientCwd">): string | null {
|
||||||
if (flow.clientBundleId != null || flow.clientCwd == null) return null;
|
if (flow.clientBundleId != null) return null;
|
||||||
const name = flow.clientCwd.replace(/\/+$/, "").split("/").pop() ?? "";
|
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;
|
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
|
* The folder that names a generic runtime's row, `node` in `fieldwork`; null for anything
|
||||||
* for a program outside an app the folder it ran in, else its name. Null when unknown.
|
* 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 {
|
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/"];
|
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.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.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.system != null && isSystemFlow(flow) !== query.system) return false;
|
||||||
if (query.tunnelled != null && flow.tunnelled !== query.tunnelled) return false;
|
if (query.tunnelled != null && flow.tunnelled !== query.tunnelled) return false;
|
||||||
// One of the groups, each judged whole, as the database judges them.
|
// 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;
|
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` */
|
/** `404` exactly, `40` as the start of one, or a class such as `4xx` */
|
||||||
function matchesStatus(status: number | null, term: string): boolean {
|
function matchesStatus(status: number | null, term: string): boolean {
|
||||||
if (status == null) return false;
|
if (status == null) return false;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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", () => {
|
describe("decrypting from the sidebar", () => {
|
||||||
const tunnel = flow("https://bank.example.com", {
|
const tunnel = flow("https://bank.example.com", {
|
||||||
...slack,
|
...slack,
|
||||||
|
|||||||
@@ -6,19 +6,31 @@ import {
|
|||||||
folderOf,
|
folderOf,
|
||||||
formatFieldFilter,
|
formatFieldFilter,
|
||||||
isSystemFlow,
|
isSystemFlow,
|
||||||
|
runtimeFolderOf,
|
||||||
sidebarPath,
|
sidebarPath,
|
||||||
siteOf,
|
siteOf,
|
||||||
tokenize,
|
tokenize,
|
||||||
} from "./search";
|
} 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 {
|
export interface SidebarItem {
|
||||||
id: string;
|
id: string;
|
||||||
kind: SidebarKind;
|
kind: SidebarKind;
|
||||||
label: string;
|
label: string;
|
||||||
/** Dim beside the label: a program's folder */
|
/** Dim beside the label: a generic runtime's folder */
|
||||||
sublabel?: string;
|
sublabel?: string;
|
||||||
/** Flows under the row, of those loaded */
|
/** Flows under the row, of those loaded */
|
||||||
count: number;
|
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 */
|
/** A row a flow sits under, with what makes it, so a rebuild only looks it up */
|
||||||
interface PlaceRow {
|
interface PlaceRow {
|
||||||
id: string;
|
id: string;
|
||||||
kind: "site" | "host" | "path";
|
kind: "folder" | "site" | "host" | "path";
|
||||||
label: string;
|
label: string;
|
||||||
query: string;
|
query: string;
|
||||||
decrypt?: DecryptSubject;
|
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
|
* 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 {
|
interface Place {
|
||||||
/** What `@app` names its app by, or null when unknown */
|
/** What `@app` names its app by, or null when unknown */
|
||||||
@@ -99,10 +112,28 @@ function placeOf(flow: Flow): Place {
|
|||||||
}
|
}
|
||||||
const system = isSystemFlow(flow);
|
const system = isSystemFlow(flow);
|
||||||
const appQuery = app == null ? "" : appQueryFor(app, system);
|
const appQuery = app == null ? "" : appQueryFor(app, system);
|
||||||
const appRows =
|
let appRows: PlaceRow[] = [];
|
||||||
app == null
|
if (app != null) {
|
||||||
? []
|
const folder = runtimeFolderOf(flow) == null ? folderOf(flow) : null;
|
||||||
: rows.map((r) => ({ ...r, id: `app:${app}|${r.id}`, query: `${appQuery} ${r.query}` }));
|
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 };
|
place = { app, system, rows, appRows };
|
||||||
places.set(flow, place);
|
places.set(flow, place);
|
||||||
return 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 {
|
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 {
|
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}`;
|
const id = `app:${place.app}`;
|
||||||
let app = parent.children.get(id);
|
let app = parent.children.get(id);
|
||||||
if (app == null) {
|
if (app == null) {
|
||||||
const folder = folderOf(flow);
|
const folder = runtimeFolderOf(flow);
|
||||||
const item: SidebarItem = {
|
const item: SidebarItem = {
|
||||||
id,
|
id,
|
||||||
kind: "app",
|
kind: "app",
|
||||||
|
|||||||
@@ -198,6 +198,37 @@ export const pausedAtAtom = atom<number | null>(null);
|
|||||||
*/
|
*/
|
||||||
let changesDuringPage: ModelPayload[] | 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 */
|
/** 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) => {
|
export const loadOlderFlowsAtom = atom(null, async (get, set) => {
|
||||||
const search = get(searchWindowAtom);
|
const search = get(searchWindowAtom);
|
||||||
@@ -206,12 +237,13 @@ export const loadOlderFlowsAtom = atom(null, async (get, set) => {
|
|||||||
if (changesDuringPage != null || !hasOlder || first == null) return;
|
if (changesDuringPage != null || !hasOlder || first == null) return;
|
||||||
const changes: ModelPayload[] = [];
|
const changes: ModelPayload[] = [];
|
||||||
changesDuringPage = changes;
|
changesDuringPage = changes;
|
||||||
|
pageStarted();
|
||||||
try {
|
try {
|
||||||
const res = await rpc("list_flows", { ...search?.query, before: first.id, limit: PAGE_SIZE });
|
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.
|
// Dropped when the window moved on meanwhile, trimmed, cleared or searched again.
|
||||||
const merge = (win: FlowWindow) => {
|
const merge = (win: FlowWindow) => {
|
||||||
if (win.flows[0]?.id !== first.id) return win;
|
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);
|
const kept = search == null ? changes : changesFor(search.query, changes);
|
||||||
// Counted when they first applied.
|
// Counted when they first applied.
|
||||||
return { ...applyChanges(merged, kept), total: merged.total };
|
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);
|
console.warn("Failed to load older flows", err);
|
||||||
} finally {
|
} finally {
|
||||||
changesDuringPage = null;
|
changesDuringPage = null;
|
||||||
|
pageSettled();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -319,21 +352,49 @@ export function syncFlows(store: Store) {
|
|||||||
load();
|
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() {
|
function load() {
|
||||||
loaded = false;
|
loaded = false;
|
||||||
const thisLoad = ++loads;
|
const thisLoad = ++loads;
|
||||||
void rpc("list_flows", { limit: WINDOW_SIZE }).then((res) => {
|
pageStarted();
|
||||||
// A clear since asked for a newer page.
|
void rpc("list_flows", { limit: WINDOW_SIZE })
|
||||||
if (thisLoad !== loads) return;
|
.then((res) => {
|
||||||
store.set(flowWindowAtom, windowFromPage(res.flows, WINDOW_SIZE, res.total));
|
// A clear since asked for a newer page.
|
||||||
seenId = res.flows[0]?.id ?? null;
|
if (thisLoad !== loads) return;
|
||||||
loaded = true;
|
store.set(
|
||||||
store.set(flowsLoadedAtom, true);
|
flowWindowAtom,
|
||||||
// Flows recorded before this install remembered it mean setup was done.
|
windowFromPage(withoutRemoved(res.flows), WINDOW_SIZE, res.total),
|
||||||
if (res.total > 0) store.set(systemProxyEverOnAtom, true);
|
);
|
||||||
flush();
|
seenId = res.flows[0]?.id ?? null;
|
||||||
recount();
|
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));
|
loadSearch(store.get(activeQueryAtom));
|
||||||
}
|
}
|
||||||
reloadFlows = load;
|
reloadFlows = load;
|
||||||
@@ -360,12 +421,16 @@ export function syncFlows(store: Store) {
|
|||||||
buffered: [],
|
buffered: [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
pageStarted();
|
||||||
void rpc("list_flows", { ...query, limit: WINDOW_SIZE })
|
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) => {
|
.catch((err) => {
|
||||||
console.warn("Failed to search flows", err);
|
console.warn("Failed to search flows", err);
|
||||||
settle(null);
|
settle(null);
|
||||||
});
|
})
|
||||||
|
.finally(pageSettled);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The sidebar takes the window's flows at once when it empties or first fills, and at most
|
// The sidebar takes the window's flows at once when it empties or first fills, and at most
|
||||||
|
|||||||
@@ -9,24 +9,35 @@ import {
|
|||||||
subscribeToPreferredAppearance,
|
subscribeToPreferredAppearance,
|
||||||
type Appearance,
|
type Appearance,
|
||||||
} from "@yaakapp-internal/theme";
|
} from "@yaakapp-internal/theme";
|
||||||
|
import { readSettingsValues, resolveAppearance, SETTINGS_VALUES_KEY } from "./settings";
|
||||||
import { showWindow } from "./tauri";
|
import { showWindow } from "./tauri";
|
||||||
|
|
||||||
setPlatformOnDocument(platformFromUserAgent(navigator.userAgent));
|
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
|
// Apply a quick initial theme based on CSS media query
|
||||||
let preferredAppearance: Appearance = getCSSAppearance();
|
let preferredAppearance: Appearance = getCSSAppearance();
|
||||||
applyTheme(preferredAppearance);
|
applyTheme();
|
||||||
|
|
||||||
// Then subscribe to accurate OS appearance detection and changes
|
// Then subscribe to accurate OS appearance detection and changes
|
||||||
subscribeToPreferredAppearance((a) => {
|
subscribeToPreferredAppearance((a) => {
|
||||||
preferredAppearance = a;
|
preferredAppearance = a;
|
||||||
applyTheme(preferredAppearance);
|
applyTheme();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show window after initial theme is applied (window starts hidden to prevent flash)
|
// Show window after initial theme is applied (window starts hidden to prevent flash)
|
||||||
showWindow().catch(console.error);
|
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;
|
const theme = appearance === "dark" ? defaultDarkTheme : defaultLightTheme;
|
||||||
applyThemeToDocument(theme);
|
applyThemeToDocument(theme);
|
||||||
if (theme.base.surface != null) {
|
if (theme.base.surface != null) {
|
||||||
|
|||||||
+22
-1
@@ -48,7 +48,28 @@ elapsedHeadersMs: number | null,
|
|||||||
/**
|
/**
|
||||||
* Milliseconds from `started_at` to the end of the response.
|
* 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 client gave up before the response ended, by resetting its HTTP/2 stream or closing
|
||||||
* the connection. Not an error.
|
* the connection. Not an error.
|
||||||
|
|||||||
+47
-2
@@ -170,6 +170,11 @@ under?: Array<string>,
|
|||||||
* it ran in (`fieldwork`).
|
* it ran in (`fieldwork`).
|
||||||
*/
|
*/
|
||||||
app?: Array<string>,
|
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`]
|
* Only flows from macOS's own daemons (true), or none of them (false), as [`is_system`]
|
||||||
* tells them by their executable.
|
* tells them by their executable.
|
||||||
@@ -212,6 +217,11 @@ before?: string, };
|
|||||||
|
|
||||||
export type FlowsClearedPayload = Record<string, never>;
|
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,
|
export type FolderSummary = { id: string, name: string,
|
||||||
/**
|
/**
|
||||||
* The folder this one is in, if any.
|
* The folder this one is in, if any.
|
||||||
@@ -282,6 +292,14 @@ systemProxyAtLaunch: boolean,
|
|||||||
* The HTTP versions offered to clients.
|
* The HTTP versions offered to clients.
|
||||||
*/
|
*/
|
||||||
httpVersion: HttpVersion,
|
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.
|
* Why the engine last failed to start, until it starts.
|
||||||
*/
|
*/
|
||||||
@@ -301,6 +319,15 @@ locked: ProxyLock | null,
|
|||||||
*/
|
*/
|
||||||
otherOwner: OtherOwner | 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 GlobalAction = "recording_toggle" | "flows_clear";
|
||||||
|
|
||||||
export type HelperRegisterRequest = Record<string, never>;
|
export type HelperRegisterRequest = Record<string, never>;
|
||||||
@@ -370,6 +397,12 @@ yaakFound: boolean, workspaces: Array<WorkspaceSummary>, };
|
|||||||
*/
|
*/
|
||||||
export type MessagePayload = { message: FlowMessage, };
|
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,
|
export type OtherOwner = { owner: Owner, pid: number, startedAt: string,
|
||||||
/**
|
/**
|
||||||
* The engine's port while it runs.
|
* The engine's port while it runs.
|
||||||
@@ -412,9 +445,11 @@ host: string, };
|
|||||||
|
|
||||||
export type RemoveGrpcSchemaRequest = { id: 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,
|
export type SaveFlowBodyRequest = { flowId: string, which: FlowBody,
|
||||||
/**
|
/**
|
||||||
@@ -504,6 +539,8 @@ name: string,
|
|||||||
*/
|
*/
|
||||||
id?: string, http: ProxySetting, https: ProxySetting, };
|
id?: string, http: ProxySetting, https: ProxySetting, };
|
||||||
|
|
||||||
|
export type SetCaptureBodyLimitRequest = { bytes: number, };
|
||||||
|
|
||||||
export type SetDecryptListRequest = {
|
export type SetDecryptListRequest = {
|
||||||
/**
|
/**
|
||||||
* Every entry, in order, as typed: a bare hostname is a `host:` entry. A built-in
|
* 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 SetHttpVersionRequest = { httpVersion: HttpVersion, };
|
||||||
|
|
||||||
|
export type SetKeepTrafficRequest = {
|
||||||
|
/**
|
||||||
|
* Null keeps everything.
|
||||||
|
*/
|
||||||
|
bytes: number | null, };
|
||||||
|
|
||||||
|
export type SetPortRequest = { port: number, };
|
||||||
|
|
||||||
export type SetRecordingRequest = { enabled: boolean, };
|
export type SetRecordingRequest = { enabled: boolean, };
|
||||||
|
|
||||||
export type SetSystemProxyAtLaunchRequest = { 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
|
||||||
|
);
|
||||||
@@ -165,6 +165,9 @@ fn missing_data_key(data_dir: &Path) -> String {
|
|||||||
pub struct Stores {
|
pub struct Stores {
|
||||||
pub db: ProxyQueryManager,
|
pub db: ProxyQueryManager,
|
||||||
pub blobs: BlobManager,
|
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 {
|
impl Stores {
|
||||||
@@ -173,9 +176,26 @@ impl Stores {
|
|||||||
pub fn open(data_dir: &Path, key: &DatabaseKey) -> std::result::Result<Self, String> {
|
pub fn open(data_dir: &Path, key: &DatabaseKey) -> std::result::Result<Self, String> {
|
||||||
let db = ProxyQueryManager::new(&data_dir.join(PROXY_DB), key)
|
let db = ProxyQueryManager::new(&data_dir.join(PROXY_DB), key)
|
||||||
.map_err(|e| format!("Failed to open proxy database: {e}"))?;
|
.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}"))?;
|
.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`
|
/// 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()))?;
|
.map_err(|e| format!("Failed to open {}: {e}", db_path.display()))?;
|
||||||
let blobs = open_blobs_read_only(&blobs_path, key.as_ref())
|
let blobs = open_blobs_read_only(&blobs_path, key.as_ref())
|
||||||
.map_err(|e| format!("Failed to open the proxy's blob store: {e}"))?;
|
.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.
|
/// Either database in `data_dir` is plain, left by a build from before encryption.
|
||||||
pub fn needs_encrypting(data_dir: &Path) -> bool {
|
pub fn needs_encrypting(data_dir: &Path) -> bool {
|
||||||
[PROXY_DB, BLOBS_DB]
|
[PROXY_DB, BLOBS_DB]
|
||||||
@@ -443,6 +511,7 @@ mod tests {
|
|||||||
"_sqlx_migrations",
|
"_sqlx_migrations",
|
||||||
"flow_clear_mark",
|
"flow_clear_mark",
|
||||||
"flow_messages",
|
"flow_messages",
|
||||||
|
"flow_sweep_pending",
|
||||||
"flows",
|
"flows",
|
||||||
"grpc_schemas"
|
"grpc_schemas"
|
||||||
]
|
]
|
||||||
@@ -530,7 +599,7 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.map(|v| v.unwrap())
|
.map(|v| v.unwrap())
|
||||||
.collect();
|
.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");
|
let mark_columns = columns(&conn, "flow_clear_mark");
|
||||||
assert_eq!(mark_columns, ["id", "max_rowid", "cleaner", "cleaner_at"]);
|
assert_eq!(mark_columns, ["id", "max_rowid", "cleaner", "cleaner_at"]);
|
||||||
drop(conn);
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::bodies;
|
use crate::bodies;
|
||||||
use crate::db::ProxyQueryManager;
|
use crate::db::{ProxyQueryManager, Stores};
|
||||||
use crate::models::{Flow, FlowBody};
|
use crate::models::{Flow, FlowBody};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
@@ -31,6 +31,10 @@ pub struct FlowFilter {
|
|||||||
/// it ran in (`fieldwork`).
|
/// it ran in (`fieldwork`).
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub app: Option<Vec<String>>,
|
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`]
|
/// Only flows from macOS's own daemons (true), or none of them (false), as [`is_system`]
|
||||||
/// tells them by their executable.
|
/// tells them by their executable.
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
@@ -65,8 +69,8 @@ pub struct FlowFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FlowFilter {
|
impl FlowFilter {
|
||||||
/// The filter from a query string's `q`, `host`, `under`, `app`, `system`, `method`,
|
/// The filter from a query string's `q`, `host`, `under`, `app`, `cwd`, `system`, `method`,
|
||||||
/// `status`, `limit`, `after` and `before`. `under` and `app` may repeat.
|
/// `status`, `limit`, `after` and `before`. `under`, `app` and `cwd` may repeat.
|
||||||
pub fn from_query(query: &str) -> Result<Self, String> {
|
pub fn from_query(query: &str) -> Result<Self, String> {
|
||||||
let mut filter = Self::default();
|
let mut filter = Self::default();
|
||||||
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
|
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
|
||||||
@@ -76,6 +80,7 @@ impl FlowFilter {
|
|||||||
"host" => filter.host = value,
|
"host" => filter.host = value,
|
||||||
"under" => filter.under.get_or_insert_default().extend(value),
|
"under" => filter.under.get_or_insert_default().extend(value),
|
||||||
"app" => filter.app.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" => {
|
"system" => {
|
||||||
filter.system = value
|
filter.system = value
|
||||||
.map(|v| v.parse().map_err(|_| format!("Invalid system {v:?}")))
|
.map(|v| v.parse().map_err(|_| format!("Invalid system {v:?}")))
|
||||||
@@ -116,7 +121,11 @@ impl FlowFilter {
|
|||||||
query.append_pair(key, value);
|
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() {
|
for value in values.iter().flatten() {
|
||||||
query.append_pair(key, value);
|
query.append_pair(key, value);
|
||||||
}
|
}
|
||||||
@@ -144,6 +153,7 @@ impl FlowFilter {
|
|||||||
|| given(&self.status)
|
|| given(&self.status)
|
||||||
|| self.under.is_some()
|
|| self.under.is_some()
|
||||||
|| self.app.is_some()
|
|| self.app.is_some()
|
||||||
|
|| self.cwd.is_some()
|
||||||
|| self.system.is_some()
|
|| self.system.is_some()
|
||||||
|| self.tunnelled.is_some()
|
|| self.tunnelled.is_some()
|
||||||
|| self.any.as_ref().is_some_and(|groups| groups.iter().any(FlowFilter::narrows))
|
|| self.any.as_ref().is_some_and(|groups| groups.iter().any(FlowFilter::narrows))
|
||||||
@@ -224,6 +234,19 @@ impl FlowFilter {
|
|||||||
}
|
}
|
||||||
sql.push(')');
|
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 {
|
match self.system {
|
||||||
Some(true) => sql.push_str(" AND is_system(client_bundle_id, client_path)"),
|
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)"),
|
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()))
|
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
|
/// A flow by id, with its number. None once it is cleared, though its row may not be deleted
|
||||||
/// yet.
|
/// yet.
|
||||||
pub fn find(db: &ProxyQueryManager, flow_id: &str) -> Option<Flow> {
|
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);
|
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]
|
#[test]
|
||||||
fn a_search_pages_back_and_counts_what_it_matches() {
|
fn a_search_pages_back_and_counts_what_it_matches() {
|
||||||
// Every third flow is a POST, newest last.
|
// Every third flow is a POST, newest last.
|
||||||
@@ -1460,4 +1655,54 @@ mod tests {
|
|||||||
("/wD+", BodyEncoding::Base64, 3)
|
("/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
@@ -59,6 +59,18 @@ pub struct Flow {
|
|||||||
pub elapsed_headers_ms: Option<i32>,
|
pub elapsed_headers_ms: Option<i32>,
|
||||||
/// Milliseconds from `started_at` to the end of the response.
|
/// Milliseconds from `started_at` to the end of the response.
|
||||||
pub elapsed_ms: Option<i32>,
|
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>,
|
pub error: Option<String>,
|
||||||
/// The client gave up before the response ended, by resetting its HTTP/2 stream or closing
|
/// The client gave up before the response ended, by resetting its HTTP/2 stream or closing
|
||||||
/// the connection. Not an error.
|
/// the connection. Not an error.
|
||||||
@@ -244,6 +256,12 @@ impl UpsertModelInfo for Flow {
|
|||||||
(GrpcStatus, self.grpc_status.into()),
|
(GrpcStatus, self.grpc_status.into()),
|
||||||
(GrpcMessage, self.grpc_message.into()),
|
(GrpcMessage, self.grpc_message.into()),
|
||||||
(Tunnelled, self.tunnelled.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::GrpcStatus,
|
||||||
FlowIden::GrpcMessage,
|
FlowIden::GrpcMessage,
|
||||||
FlowIden::Tunnelled,
|
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")?,
|
response_truncated: r.get("response_truncated")?,
|
||||||
elapsed_headers_ms: r.get("elapsed_headers_ms")?,
|
elapsed_headers_ms: r.get("elapsed_headers_ms")?,
|
||||||
elapsed_ms: r.get("elapsed_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")?,
|
error: r.get("error")?,
|
||||||
cancelled: added_column(r, "cancelled")?.unwrap_or_default(),
|
cancelled: added_column(r, "cancelled")?.unwrap_or_default(),
|
||||||
saved_request_id: added_column(r, "saved_request_id")?,
|
saved_request_id: added_column(r, "saved_request_id")?,
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ pub struct ProxySettings {
|
|||||||
pub system_proxy_at_launch: bool,
|
pub system_proxy_at_launch: bool,
|
||||||
/// Bytes of each request and response body stored per flow.
|
/// Bytes of each request and response body stored per flow.
|
||||||
pub capture_body_limit: usize,
|
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.
|
/// The HTTP versions offered to clients in intercepted HTTPS.
|
||||||
pub http_version: HttpVersion,
|
pub http_version: HttpVersion,
|
||||||
/// The user's decrypt entries, in order: `host:api.example.com`, `app:Google Chrome`, `*`,
|
/// The user's decrypt entries, in order: `host:api.example.com`, `app:Google Chrome`, `*`,
|
||||||
@@ -31,6 +34,7 @@ impl Default for ProxySettings {
|
|||||||
port: 9090,
|
port: 9090,
|
||||||
system_proxy_at_launch: true,
|
system_proxy_at_launch: true,
|
||||||
capture_body_limit: DEFAULT_CAPTURE_BODY_LIMIT,
|
capture_body_limit: DEFAULT_CAPTURE_BODY_LIMIT,
|
||||||
|
keep_traffic_bytes: None,
|
||||||
http_version: HttpVersion::Auto,
|
http_version: HttpVersion::Auto,
|
||||||
decrypt: Vec::new(),
|
decrypt: Vec::new(),
|
||||||
decrypt_built_ins_removed: Vec::new(),
|
decrypt_built_ins_removed: Vec::new(),
|
||||||
@@ -178,6 +182,7 @@ mod tests {
|
|||||||
assert_eq!(settings.port, 9090);
|
assert_eq!(settings.port, 9090);
|
||||||
assert!(settings.system_proxy_at_launch);
|
assert!(settings.system_proxy_at_launch);
|
||||||
assert_eq!(settings.capture_body_limit, 10 * 1024 * 1024);
|
assert_eq!(settings.capture_body_limit, 10 * 1024 * 1024);
|
||||||
|
assert_eq!(settings.keep_traffic_bytes, None, "everything is kept");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -197,6 +202,7 @@ mod tests {
|
|||||||
port: 8080,
|
port: 8080,
|
||||||
system_proxy_at_launch: false,
|
system_proxy_at_launch: false,
|
||||||
capture_body_limit: 1024,
|
capture_body_limit: 1024,
|
||||||
|
keep_traffic_bytes: Some(1 << 30),
|
||||||
http_version: HttpVersion::Http1Only,
|
http_version: HttpVersion::Http1Only,
|
||||||
decrypt: vec!["host:api.example.com".into()],
|
decrypt: vec!["host:api.example.com".into()],
|
||||||
decrypt_built_ins_removed: vec!["!host:*.zoom.us".into()],
|
decrypt_built_ins_removed: vec!["!host:*.zoom.us".into()],
|
||||||
@@ -212,6 +218,7 @@ mod tests {
|
|||||||
"port": 8080,
|
"port": 8080,
|
||||||
"systemProxyAtLaunch": false,
|
"systemProxyAtLaunch": false,
|
||||||
"captureBodyLimit": 1024,
|
"captureBodyLimit": 1024,
|
||||||
|
"keepTrafficBytes": 1073741824,
|
||||||
"httpVersion": "http1Only",
|
"httpVersion": "http1Only",
|
||||||
"decrypt": ["host:api.example.com"],
|
"decrypt": ["host:api.example.com"],
|
||||||
"decryptBuiltInsRemoved": ["!host:*.zoom.us"],
|
"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,
|
"recording": true,
|
||||||
"systemProxyAtLaunch": false,
|
"systemProxyAtLaunch": false,
|
||||||
"httpVersion": "auto",
|
"httpVersion": "auto",
|
||||||
|
"captureBodyLimit": CAPTURE_LIMIT,
|
||||||
|
"keepTrafficBytes": null,
|
||||||
"startError": null,
|
"startError": null,
|
||||||
"encrypting": null,
|
"encrypting": null,
|
||||||
"locked": null,
|
"locked": null,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"dialog:allow-save",
|
"dialog:allow-save",
|
||||||
"opener:allow-default-urls",
|
"opener:allow-default-urls",
|
||||||
"opener:allow-open-url",
|
"opener:allow-open-url",
|
||||||
|
"opener:allow-reveal-item-in-dir",
|
||||||
"os:allow-os-type",
|
"os:allow-os-type",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-is-fullscreen",
|
"core:window:allow-is-fullscreen",
|
||||||
|
|||||||
@@ -177,4 +177,21 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(target("../yaak-app-client/vendored/protoc/include"), Some(PROTOC_INCLUDE_DIR));
|
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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -56,6 +56,8 @@ pub(crate) struct CaptureBody {
|
|||||||
id: u64,
|
id: u64,
|
||||||
start: Instant,
|
start: Instant,
|
||||||
engine: Arc<Engine>,
|
engine: Arc<Engine>,
|
||||||
|
/// The capture limit as the body started, so a change applies to bodies after it.
|
||||||
|
limit: usize,
|
||||||
captured: usize,
|
captured: usize,
|
||||||
size: u64,
|
size: u64,
|
||||||
finished: bool,
|
finished: bool,
|
||||||
@@ -77,6 +79,7 @@ impl CaptureBody {
|
|||||||
direction,
|
direction,
|
||||||
id,
|
id,
|
||||||
start,
|
start,
|
||||||
|
limit: engine.capture_body_limit(),
|
||||||
engine,
|
engine,
|
||||||
captured: 0,
|
captured: 0,
|
||||||
size: 0,
|
size: 0,
|
||||||
@@ -122,16 +125,20 @@ impl CaptureBody {
|
|||||||
self.emit_messages(messages);
|
self.emit_messages(messages);
|
||||||
}
|
}
|
||||||
let truncated = self.size > self.captured as u64;
|
let truncated = self.size > self.captured as u64;
|
||||||
|
let elapsed_ms = self.start.elapsed().as_millis() as u64;
|
||||||
let event = match self.direction {
|
let event = match self.direction {
|
||||||
Direction::Request if self.size == 0 => return,
|
Direction::Request if self.size == 0 => return,
|
||||||
Direction::Request => {
|
Direction::Request => ProxyEvent::RequestBodyComplete {
|
||||||
ProxyEvent::RequestBodyComplete { id: self.id, size: self.size, truncated }
|
id: self.id,
|
||||||
}
|
size: self.size,
|
||||||
|
truncated,
|
||||||
|
elapsed_ms,
|
||||||
|
},
|
||||||
Direction::Response => ProxyEvent::ResponseBodyComplete {
|
Direction::Response => ProxyEvent::ResponseBodyComplete {
|
||||||
id: self.id,
|
id: self.id,
|
||||||
size: self.size,
|
size: self.size,
|
||||||
truncated,
|
truncated,
|
||||||
elapsed_ms: self.start.elapsed().as_millis() as u64,
|
elapsed_ms,
|
||||||
error,
|
error,
|
||||||
cancelled,
|
cancelled,
|
||||||
},
|
},
|
||||||
@@ -153,7 +160,7 @@ impl Body for CaptureBody {
|
|||||||
Poll::Ready(Some(Ok(frame))) => {
|
Poll::Ready(Some(Ok(frame))) => {
|
||||||
if let Some(frame_data) = frame.data_ref() {
|
if let Some(frame_data) = frame.data_ref() {
|
||||||
let len = frame_data.len();
|
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));
|
let data = frame_data.slice(..len.min(room));
|
||||||
self.captured += data.len();
|
self.captured += data.len();
|
||||||
self.size += len as u64;
|
self.size += len as u64;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use std::collections::hash_map::RandomState;
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::hash::{BuildHasher, Hasher};
|
use std::hash::{BuildHasher, Hasher};
|
||||||
use std::net::SocketAddr;
|
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::mpsc as std_mpsc;
|
||||||
use std::sync::{Arc, Mutex, RwLock};
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
@@ -139,6 +139,21 @@ pub enum ProxyEvent {
|
|||||||
size: u64,
|
size: u64,
|
||||||
/// Whether `size` exceeded `capture_body_limit`.
|
/// Whether `size` exceeded `capture_body_limit`.
|
||||||
truncated: bool,
|
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.
|
/// Response headers arrived from upstream.
|
||||||
ResponseStart {
|
ResponseStart {
|
||||||
@@ -250,6 +265,11 @@ impl ProxyHandle {
|
|||||||
self.engine.http2.store(enabled, Ordering::Relaxed);
|
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
|
/// Decides which HTTPS connections are decrypted from the next CONNECT on. Connections
|
||||||
/// already open stay decrypted or tunnelled as they were.
|
/// already open stay decrypted or tunnelled as they were.
|
||||||
pub fn set_decrypt_policy(&self, policy: DecryptPolicy) {
|
pub fn set_decrypt_policy(&self, policy: DecryptPolicy) {
|
||||||
@@ -285,7 +305,7 @@ pub(crate) struct ClientConn {
|
|||||||
|
|
||||||
pub(crate) struct Engine {
|
pub(crate) struct Engine {
|
||||||
ca: Arc<CaStore>,
|
ca: Arc<CaStore>,
|
||||||
capture_body_limit: usize,
|
capture_body_limit: AtomicUsize,
|
||||||
events: std_mpsc::Sender<ProxyEvent>,
|
events: std_mpsc::Sender<ProxyEvent>,
|
||||||
next_id: AtomicU64,
|
next_id: AtomicU64,
|
||||||
next_connection: AtomicU64,
|
next_connection: AtomicU64,
|
||||||
@@ -309,6 +329,11 @@ impl Engine {
|
|||||||
let _ = self.events.send(event);
|
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 {
|
fn next_flow_id(&self) -> u64 {
|
||||||
self.next_id.fetch_add(1, Ordering::Relaxed)
|
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 (event_tx, event_rx) = std_mpsc::channel();
|
||||||
let engine = Arc::new(Engine {
|
let engine = Arc::new(Engine {
|
||||||
ca: config.ca,
|
ca: config.ca,
|
||||||
capture_body_limit: config.capture_body_limit,
|
capture_body_limit: AtomicUsize::new(config.capture_body_limit),
|
||||||
events: event_tx,
|
events: event_tx,
|
||||||
next_id: AtomicU64::new(1),
|
next_id: AtomicU64::new(1),
|
||||||
next_connection: AtomicU64::new(1),
|
next_connection: AtomicU64::new(1),
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ impl Flow {
|
|||||||
|
|
||||||
fn capture_request(&self, body: Incoming) -> BoxBody {
|
fn capture_request(&self, body: Incoming) -> BoxBody {
|
||||||
let parser = self.grpc.as_ref().map(|call| {
|
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 encoding = call.encoding.as_deref();
|
||||||
let parser = GrpcParser::new(MessageDirection::Client, encoding, call.text, limit)
|
let parser = GrpcParser::new(MessageDirection::Client, encoding, call.text, limit)
|
||||||
.with_content_encoding(call.content_encoding.as_deref());
|
.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
|
/// How the response's body is read for messages, if it streams them, after recording that
|
||||||
/// it does.
|
/// it does.
|
||||||
fn response_parser(&self, resp: &Response<Incoming>) -> Option<BodyParser> {
|
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();
|
let headers = resp.headers();
|
||||||
if self.grpc.is_some() {
|
if self.grpc.is_some() {
|
||||||
// A call that fails at once answers with its status in the headers, and no body.
|
// 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))))
|
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>) {
|
fn respond_head(&mut self, resp: &Response<Incoming>, remote_addr: Option<SocketAddr>) {
|
||||||
self.answered = true;
|
self.answered = true;
|
||||||
self.engine.emit(ProxyEvent::ResponseStart {
|
self.engine.emit(ProxyEvent::ResponseStart {
|
||||||
@@ -576,9 +591,36 @@ enum Upstream {
|
|||||||
struct UpstreamConn {
|
struct UpstreamConn {
|
||||||
sender: Upstream,
|
sender: Upstream,
|
||||||
remote_addr: Option<SocketAddr>,
|
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 {
|
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 {
|
fn is_h2(&self) -> bool {
|
||||||
matches!(self.sender, Upstream::H2(_))
|
matches!(self.sender, Upstream::H2(_))
|
||||||
}
|
}
|
||||||
@@ -636,10 +678,7 @@ impl Upstreams {
|
|||||||
conns.retain(|c| !c.is_closed());
|
conns.retain(|c| !c.is_closed());
|
||||||
if let Some(i) = conns.iter().position(UpstreamConn::is_ready) {
|
if let Some(i) = conns.iter().position(UpstreamConn::is_ready) {
|
||||||
let conn = match &conns[i].sender {
|
let conn = match &conns[i].sender {
|
||||||
Upstream::H2(s) => UpstreamConn {
|
Upstream::H2(s) => conns[i].share(Upstream::H2(s.clone())),
|
||||||
sender: Upstream::H2(s.clone()),
|
|
||||||
remote_addr: conns[i].remote_addr,
|
|
||||||
},
|
|
||||||
Upstream::H1(_) => conns.swap_remove(i),
|
Upstream::H1(_) => conns.swap_remove(i),
|
||||||
};
|
};
|
||||||
return Ok((conn, true));
|
return Ok((conn, true));
|
||||||
@@ -650,10 +689,7 @@ impl Upstreams {
|
|||||||
}
|
}
|
||||||
let conn = connect_upstream(engine, &self.target, false).await?;
|
let conn = connect_upstream(engine, &self.target, false).await?;
|
||||||
match &conn.sender {
|
match &conn.sender {
|
||||||
Upstream::H2(s) => conns.push(UpstreamConn {
|
Upstream::H2(s) => conns.push(conn.share(Upstream::H2(s.clone()))),
|
||||||
sender: Upstream::H2(s.clone()),
|
|
||||||
remote_addr: conn.remote_addr,
|
|
||||||
}),
|
|
||||||
Upstream::H1(_) => self.h1.store(true, Ordering::Relaxed),
|
Upstream::H1(_) => self.h1.store(true, Ordering::Relaxed),
|
||||||
}
|
}
|
||||||
Ok((conn, false))
|
Ok((conn, false))
|
||||||
@@ -682,10 +718,12 @@ async fn connect_upstream(
|
|||||||
h1_only: bool,
|
h1_only: bool,
|
||||||
) -> Result<UpstreamConn, BoxError> {
|
) -> Result<UpstreamConn, BoxError> {
|
||||||
let tcp = TcpStream::connect(&target.addr).await?;
|
let tcp = TcpStream::connect(&target.addr).await?;
|
||||||
|
let connected = Instant::now();
|
||||||
let remote_addr = tcp.peer_addr().ok();
|
let remote_addr = tcp.peer_addr().ok();
|
||||||
let config = if h1_only { &engine.upstream_tls_h1 } else { &engine.upstream_tls };
|
let config = if h1_only { &engine.upstream_tls_h1 } else { &engine.upstream_tls };
|
||||||
let server_name = ServerName::try_from(target.server_name.clone())?;
|
let server_name = ServerName::try_from(target.server_name.clone())?;
|
||||||
let tls = TlsConnector::from(config.clone()).connect(server_name, tcp).await?;
|
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 is_h2 = tls.get_ref().1.alpn_protocol() == Some(b"h2");
|
||||||
let io = TokioIo::new(tls);
|
let io = TokioIo::new(tls);
|
||||||
|
|
||||||
@@ -711,7 +749,7 @@ async fn connect_upstream(
|
|||||||
});
|
});
|
||||||
Upstream::H1(sender)
|
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
|
/// 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,
|
Ok(taken) => taken,
|
||||||
Err(e) => return Ok(flow.fail(error_chain(e.as_ref()))),
|
Err(e) => return Ok(flow.fail(error_chain(e.as_ref()))),
|
||||||
};
|
};
|
||||||
|
flow.goes_on(&conn, reused);
|
||||||
|
|
||||||
let (mut parts, body) = req.into_parts();
|
let (mut parts, body) = req.into_parts();
|
||||||
if let Err(e) = address(&mut parts, &conn, target, &path, !body.is_end_stream()) {
|
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()))),
|
Err(e) => return Ok(flow.fail(error_chain(e.as_ref()))),
|
||||||
};
|
};
|
||||||
reused = false;
|
reused = false;
|
||||||
|
flow.goes_on(&conn, reused);
|
||||||
let (mut parts, body) = unsent.into_parts();
|
let (mut parts, body) = unsent.into_parts();
|
||||||
if let Err(e) = address(&mut parts, &conn, target, &path, !body.is_end_stream()) {
|
if let Err(e) = address(&mut parts, &conn, target, &path, !body.is_end_stream()) {
|
||||||
return Ok(flow.fail(e));
|
return Ok(flow.fail(e));
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ pub(crate) async fn relay(
|
|||||||
};
|
};
|
||||||
let (client_read, client_write) = tokio::io::split(TokioIo::new(client));
|
let (client_read, client_write) = tokio::io::split(TokioIo::new(client));
|
||||||
let (upstream_read, upstream_write) = tokio::io::split(TokioIo::new(upstream));
|
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(
|
let outbound = FrameParser::new(
|
||||||
MessageDirection::Client,
|
MessageDirection::Client,
|
||||||
flow.clone(),
|
flow.clone(),
|
||||||
|
|||||||
@@ -415,6 +415,7 @@ fn flow_id(event: &ProxyEvent) -> u64 {
|
|||||||
| ProxyEvent::RequestHeader { id, .. }
|
| ProxyEvent::RequestHeader { id, .. }
|
||||||
| ProxyEvent::RequestBodyChunk { id, .. }
|
| ProxyEvent::RequestBodyChunk { id, .. }
|
||||||
| ProxyEvent::RequestBodyComplete { id, .. }
|
| ProxyEvent::RequestBodyComplete { id, .. }
|
||||||
|
| ProxyEvent::UpstreamConnection { id, .. }
|
||||||
| ProxyEvent::ResponseStart { id, .. }
|
| ProxyEvent::ResponseStart { id, .. }
|
||||||
| ProxyEvent::ResponseHeader { id, .. }
|
| ProxyEvent::ResponseHeader { id, .. }
|
||||||
| ProxyEvent::ResponseBodyChunk { id, .. }
|
| ProxyEvent::ResponseBodyChunk { id, .. }
|
||||||
@@ -1710,6 +1711,29 @@ async fn h2_client_to_an_h2_upstream() {
|
|||||||
(*size, error.clone(), *cancelled));
|
(*size, error.clone(), *cancelled));
|
||||||
assert_eq!((size, error, cancelled), (text.len() as u64, None, false));
|
assert_eq!((size, error, cancelled), (text.len() as u64, None, false));
|
||||||
assert_eq!(body(&events, false).0, text);
|
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")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export function AutoScroller<T>({
|
|||||||
className={classNames(
|
className={classNames(
|
||||||
"absolute bottom-3 left-1/2 -translate-x-1/2 z-10",
|
"absolute bottom-3 left-1/2 -translate-x-1/2 z-10",
|
||||||
"flex items-center gap-1.5 h-6 px-3 rounded-full",
|
"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",
|
"hover:bg-surface-highlight",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -106,6 +106,18 @@ export interface HttpRequestPaneViewProps {
|
|||||||
|
|
||||||
/** Tabs only the app has, after the shared ones */
|
/** Tabs only the app has, after the shared ones */
|
||||||
extraTabs?: ExtraPaneTab[];
|
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 }[];
|
type HeadersEditorInherited = { name: string; value: string; enabled?: boolean; id?: string }[];
|
||||||
@@ -153,6 +165,10 @@ export function HttpRequestPaneView({
|
|||||||
authTabs,
|
authTabs,
|
||||||
authContent,
|
authContent,
|
||||||
extraTabs,
|
extraTabs,
|
||||||
|
urlLine,
|
||||||
|
dimEmptyTabs = false,
|
||||||
|
defaultTab,
|
||||||
|
onTabChange,
|
||||||
}: HttpRequestPaneViewProps) {
|
}: HttpRequestPaneViewProps) {
|
||||||
const { ErrorBoundary } = useEditorHost();
|
const { ErrorBoundary } = useEditorHost();
|
||||||
const readOnly = !editable;
|
const readOnly = !editable;
|
||||||
@@ -168,27 +184,27 @@ export function HttpRequestPaneView({
|
|||||||
: 0;
|
: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headerCount = request.headers.filter((h) => h.name && h.enabled !== false).length;
|
||||||
const tabs = useMemo<TabItem[]>(
|
const tabs = useMemo<TabItem[]>(
|
||||||
() => [
|
() => [
|
||||||
bodyTab ?? {
|
bodyTab ?? {
|
||||||
value: REQUEST_TAB_BODY,
|
value: REQUEST_TAB_BODY,
|
||||||
label: bodyTypeLabel(request.bodyType, request.contentType),
|
label: bodyTypeLabel(request.bodyType, request.contentType),
|
||||||
|
empty: dimEmptyTabs && request.bodyType == null,
|
||||||
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
|
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: REQUEST_TAB_PARAMS,
|
value: REQUEST_TAB_PARAMS,
|
||||||
rightSlot: <CountBadge count={paramPairs.length} />,
|
rightSlot: <CountBadge count={paramPairs.length} />,
|
||||||
label: "Params",
|
label: "Params",
|
||||||
|
empty: dimEmptyTabs && paramPairs.length === 0,
|
||||||
},
|
},
|
||||||
...(headersTabs ?? [
|
...(headersTabs ?? [
|
||||||
{
|
{
|
||||||
value: REQUEST_TAB_HEADERS,
|
value: REQUEST_TAB_HEADERS,
|
||||||
label: "Headers",
|
label: "Headers",
|
||||||
rightSlot: (
|
empty: dimEmptyTabs && headerCount === 0,
|
||||||
<CountBadge
|
rightSlot: <CountBadge count={headerCount} />,
|
||||||
count={request.headers.filter((h) => h.name && h.enabled !== false).length}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
...(authTabs ??
|
...(authTabs ??
|
||||||
@@ -205,14 +221,15 @@ export function HttpRequestPaneView({
|
|||||||
[
|
[
|
||||||
authTabs,
|
authTabs,
|
||||||
bodyTab,
|
bodyTab,
|
||||||
|
dimEmptyTabs,
|
||||||
extraTabs,
|
extraTabs,
|
||||||
|
headerCount,
|
||||||
headersTabs,
|
headersTabs,
|
||||||
numParams,
|
numParams,
|
||||||
paramPairs.length,
|
paramPairs.length,
|
||||||
request.auth,
|
request.auth,
|
||||||
request.bodyType,
|
request.bodyType,
|
||||||
request.contentType,
|
request.contentType,
|
||||||
request.headers,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -235,6 +252,7 @@ export function HttpRequestPaneView({
|
|||||||
style={style}
|
style={style}
|
||||||
className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1")}
|
className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1")}
|
||||||
>
|
>
|
||||||
|
{urlLine ?? (
|
||||||
<UrlBar
|
<UrlBar
|
||||||
stateKey={`url.${request.id}`}
|
stateKey={`url.${request.id}`}
|
||||||
key={urlBarKey}
|
key={urlBarKey}
|
||||||
@@ -264,6 +282,7 @@ export function HttpRequestPaneView({
|
|||||||
forceUpdateKey={urlForceUpdateKey ?? forceUpdateKey}
|
forceUpdateKey={urlForceUpdateKey ?? forceUpdateKey}
|
||||||
isLoading={isSending}
|
isLoading={isSending}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<Tabs
|
<Tabs
|
||||||
ref={tabsRef}
|
ref={tabsRef}
|
||||||
label="Request"
|
label="Request"
|
||||||
@@ -271,6 +290,9 @@ export function HttpRequestPaneView({
|
|||||||
tabListClassName="mt-1 -mb-1.5"
|
tabListClassName="mt-1 -mb-1.5"
|
||||||
storageKey={tabsStorageKey}
|
storageKey={tabsStorageKey}
|
||||||
activeTabKey={request.id}
|
activeTabKey={request.id}
|
||||||
|
defaultValue={defaultTab}
|
||||||
|
keyedDefault={defaultTab != null}
|
||||||
|
onChangeValue={onTabChange}
|
||||||
>
|
>
|
||||||
<TabContent value={REQUEST_TAB_AUTH} ErrorBoundary={ErrorBoundary}>
|
<TabContent value={REQUEST_TAB_AUTH} ErrorBoundary={ErrorBoundary}>
|
||||||
{authContent ?? (request.auth != null && <AuthDisplay auth={request.auth} />)}
|
{authContent ?? (request.auth != null && <AuthDisplay auth={request.auth} />)}
|
||||||
|
|||||||
@@ -69,6 +69,22 @@ export interface HttpResponsePaneViewProps {
|
|||||||
infoRows?: ReactElement[];
|
infoRows?: ReactElement[];
|
||||||
/** The copy button for a body held back for its size */
|
/** The copy button for a body held back for its size */
|
||||||
renderCopyButton?: (getText: () => Promise<string | null>) => ReactNode;
|
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 */
|
/** Yaak's response pane: a status line over Response, Request, Headers and Cookies tabs */
|
||||||
@@ -95,6 +111,11 @@ export function HttpResponsePaneView({
|
|||||||
renderBodyViewer,
|
renderBodyViewer,
|
||||||
infoRows,
|
infoRows,
|
||||||
renderCopyButton,
|
renderCopyButton,
|
||||||
|
requestShownApart = false,
|
||||||
|
bare = false,
|
||||||
|
dimEmptyTabs = false,
|
||||||
|
defaultTab,
|
||||||
|
onTabChange,
|
||||||
}: HttpResponsePaneViewProps) {
|
}: HttpResponsePaneViewProps) {
|
||||||
const { ErrorBoundary } = useEditorHost();
|
const { ErrorBoundary } = useEditorHost();
|
||||||
const mimeType =
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
value: RESPONSE_TAB_BODY,
|
value: RESPONSE_TAB_BODY,
|
||||||
|
empty: dimEmptyTabs && noBody,
|
||||||
options: {
|
options: {
|
||||||
value: viewMode,
|
value: viewMode,
|
||||||
onChange: (v) => onViewModeChange(v === "raw" ? "raw" : "pretty"),
|
onChange: (v) => onViewModeChange(v === "raw" ? "raw" : "pretty"),
|
||||||
@@ -153,40 +180,48 @@ export function HttpResponsePaneView({
|
|||||||
itemsAfter: itemsAfter.length > 0 ? itemsAfter : undefined,
|
itemsAfter: itemsAfter.length > 0 ? itemsAfter : undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
...(requestShownApart
|
||||||
value: RESPONSE_TAB_REQUEST,
|
? []
|
||||||
label: "Request",
|
: [
|
||||||
rightSlot: (response?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
|
{
|
||||||
},
|
value: RESPONSE_TAB_REQUEST,
|
||||||
|
label: "Request",
|
||||||
|
rightSlot:
|
||||||
|
(response?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
{
|
{
|
||||||
value: RESPONSE_TAB_HEADERS,
|
value: RESPONSE_TAB_HEADERS,
|
||||||
label: "Headers",
|
label: "Headers",
|
||||||
rightSlot: (
|
empty: dimEmptyTabs && headerCount === 0,
|
||||||
<CountBadge
|
rightSlot: requestShownApart ? (
|
||||||
count={response?.requestHeaders.length ?? 0}
|
<CountBadge count={headerCount} />
|
||||||
count2={response?.headers.length ?? 0}
|
) : (
|
||||||
showZero
|
<CountBadge count={response?.requestHeaders.length ?? 0} count2={headerCount} showZero />
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: RESPONSE_TAB_COOKIES,
|
value: RESPONSE_TAB_COOKIES,
|
||||||
label: "Cookies",
|
label: "Cookies",
|
||||||
rightSlot:
|
empty: dimEmptyTabs && cookieCount === 0,
|
||||||
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
|
rightSlot: requestShownApart ? (
|
||||||
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
|
<CountBadge count={cookieCounts.received} />
|
||||||
) : null,
|
) : cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
|
||||||
|
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
|
||||||
|
) : null,
|
||||||
},
|
},
|
||||||
...(extraTabs?.map((t) => t.tab) ?? []),
|
...(extraTabs?.map((t) => t.tab) ?? []),
|
||||||
];
|
];
|
||||||
}, [
|
}, [
|
||||||
cookieCounts,
|
cookieCounts,
|
||||||
|
dimEmptyTabs,
|
||||||
extraTabs,
|
extraTabs,
|
||||||
mimeType,
|
mimeType,
|
||||||
onCopyAsCurl,
|
onCopyAsCurl,
|
||||||
onCopyBody,
|
onCopyBody,
|
||||||
onSaveBody,
|
onSaveBody,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
|
requestShownApart,
|
||||||
response,
|
response,
|
||||||
viewMode,
|
viewMode,
|
||||||
]);
|
]);
|
||||||
@@ -197,9 +232,8 @@ export function HttpResponsePaneView({
|
|||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
"x-theme-responsePane",
|
"x-theme-responsePane",
|
||||||
"max-h-full h-full",
|
"max-h-full h-full overflow-hidden relative",
|
||||||
"bg-surface rounded-md border border-border-subtle overflow-hidden",
|
!bare && "bg-surface rounded-md border border-border-subtle",
|
||||||
"relative",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{response == null ? (
|
{response == null ? (
|
||||||
@@ -234,6 +268,9 @@ export function HttpResponsePaneView({
|
|||||||
tabListClassName="mt-0.5 -mb-1.5"
|
tabListClassName="mt-0.5 -mb-1.5"
|
||||||
storageKey={tabsStorageKey}
|
storageKey={tabsStorageKey}
|
||||||
activeTabKey={activeTabKey}
|
activeTabKey={activeTabKey}
|
||||||
|
defaultValue={defaultTab}
|
||||||
|
keyedDefault={defaultTab != null}
|
||||||
|
onChangeValue={onTabChange}
|
||||||
>
|
>
|
||||||
<TabContent value={RESPONSE_TAB_BODY} ErrorBoundary={ErrorBoundary}>
|
<TabContent value={RESPONSE_TAB_BODY} ErrorBoundary={ErrorBoundary}>
|
||||||
<ErrorBoundary name="Http Response Viewer">
|
<ErrorBoundary name="Http Response Viewer">
|
||||||
@@ -259,6 +296,7 @@ export function HttpResponsePaneView({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
|
{!requestShownApart && (
|
||||||
<TabContent value={RESPONSE_TAB_REQUEST} ErrorBoundary={ErrorBoundary}>
|
<TabContent value={RESPONSE_TAB_REQUEST} ErrorBoundary={ErrorBoundary}>
|
||||||
<ConfirmLargeResponse
|
<ConfirmLargeResponse
|
||||||
contentLength={response.requestContentLength}
|
contentLength={response.requestContentLength}
|
||||||
@@ -277,11 +315,19 @@ export function HttpResponsePaneView({
|
|||||||
)}
|
)}
|
||||||
</ConfirmLargeResponse>
|
</ConfirmLargeResponse>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
|
)}
|
||||||
<TabContent value={RESPONSE_TAB_HEADERS} ErrorBoundary={ErrorBoundary}>
|
<TabContent value={RESPONSE_TAB_HEADERS} ErrorBoundary={ErrorBoundary}>
|
||||||
<ResponseHeaders response={response} infoRows={infoRows} />
|
<ResponseHeaders
|
||||||
|
response={response}
|
||||||
|
infoRows={infoRows}
|
||||||
|
responseOnly={requestShownApart}
|
||||||
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={RESPONSE_TAB_COOKIES} ErrorBoundary={ErrorBoundary}>
|
<TabContent value={RESPONSE_TAB_COOKIES} ErrorBoundary={ErrorBoundary}>
|
||||||
<ResponseCookies response={response} />
|
<ResponseCookies
|
||||||
|
response={response}
|
||||||
|
side={requestShownApart ? "received" : undefined}
|
||||||
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
{extraTabs?.map(({ tab, content }) => (
|
{extraTabs?.map(({ tab, content }) => (
|
||||||
<TabContent key={tab.value} value={tab.value} ErrorBoundary={ErrorBoundary}>
|
<TabContent key={tab.value} value={tab.value} ErrorBoundary={ErrorBoundary}>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import type { HttpResponseView } from "./types";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
response: HttpResponseView;
|
response: HttpResponseView;
|
||||||
|
/** Only the cookies sent or only those received, for a pane that shows one side */
|
||||||
|
side?: "sent" | "received";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedCookie {
|
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 { useDetailsBannerStoredValue } = useEditorHost();
|
||||||
const { sentCookies, receivedCookies } = useMemo(
|
const { sentCookies, receivedCookies } = useMemo(
|
||||||
() => parseCookieHeaders(response.cookieHeaders),
|
() => parseCookieHeaders(response.cookieHeaders),
|
||||||
@@ -111,6 +113,7 @@ export function ResponseCookies({ response }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
||||||
|
{side !== "received" && (
|
||||||
<DetailsBanner
|
<DetailsBanner
|
||||||
defaultOpen
|
defaultOpen
|
||||||
useStoredValue={useDetailsBannerStoredValue}
|
useStoredValue={useDetailsBannerStoredValue}
|
||||||
@@ -134,7 +137,9 @@ export function ResponseCookies({ response }: Props) {
|
|||||||
</KeyValueRows>
|
</KeyValueRows>
|
||||||
)}
|
)}
|
||||||
</DetailsBanner>
|
</DetailsBanner>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{side !== "sent" && (
|
||||||
<DetailsBanner
|
<DetailsBanner
|
||||||
defaultOpen
|
defaultOpen
|
||||||
useStoredValue={useDetailsBannerStoredValue}
|
useStoredValue={useDetailsBannerStoredValue}
|
||||||
@@ -213,6 +218,7 @@ export function ResponseCookies({ response }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DetailsBanner>
|
</DetailsBanner>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { format, formatDistanceToNowStrict } from "date-fns";
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { DetailsBanner } from "../DetailsBanner";
|
import { DetailsBanner } from "../DetailsBanner";
|
||||||
import { useEditorHost } from "../EditorHost";
|
import { useEditorHost } from "../EditorHost";
|
||||||
|
import { EmptyStateText } from "../EmptyStateText";
|
||||||
import { HeadersTable } from "../HeadersTable";
|
import { HeadersTable } from "../HeadersTable";
|
||||||
import { IconButton } from "../IconButton";
|
import { IconButton } from "../IconButton";
|
||||||
import type { KeyValueRowProps } from "../KeyValueRow";
|
import type { KeyValueRowProps } from "../KeyValueRow";
|
||||||
@@ -12,10 +13,30 @@ interface Props {
|
|||||||
response: HttpResponseView;
|
response: HttpResponseView;
|
||||||
/** More rows for the Info section, after the standard ones, each keyed */
|
/** More rows for the Info section, after the standard ones, each keyed */
|
||||||
infoRows?: ReactElement[];
|
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();
|
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 (
|
return (
|
||||||
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
||||||
<DetailsBanner
|
<DetailsBanner
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ import {
|
|||||||
SettingsList,
|
SettingsList,
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
} from "./SettingRow";
|
} from "./SettingRow";
|
||||||
import type { SettingsCallbacks, SettingsEditorKeymap, SettingsView } from "./types";
|
import type {
|
||||||
|
SettingsCallbacks,
|
||||||
|
SettingsEditorKeymap,
|
||||||
|
SettingsInterfaceRow,
|
||||||
|
SettingsView,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
const NULL_FONT_VALUE = "__NULL_FONT__";
|
const NULL_FONT_VALUE = "__NULL_FONT__";
|
||||||
|
|
||||||
@@ -44,6 +49,7 @@ export function SettingsInterface({
|
|||||||
trailingSections,
|
trailingSections,
|
||||||
}: SettingsInterfaceProps) {
|
}: SettingsInterfaceProps) {
|
||||||
const { app, values, fonts } = view;
|
const { app, values, fonts } = view;
|
||||||
|
const shown = (row: SettingsInterfaceRow) => !view.hiddenInterfaceRows?.includes(row);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack space={1.5} className="mb-4">
|
<VStack space={1.5} className="mb-4">
|
||||||
@@ -125,43 +131,55 @@ export function SettingsInterface({
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection title="Editor">
|
{(shown("editorKeymap") || shown("editorSoftWrap") || shown("coloredMethods")) && (
|
||||||
<SettingRowSelect
|
<SettingsSection title="Editor">
|
||||||
name="editorKeymap"
|
{shown("editorKeymap") && (
|
||||||
title="Editor keymap"
|
<SettingRowSelect
|
||||||
description="Keyboard shortcut preset used by text editors."
|
name="editorKeymap"
|
||||||
value={values.editorKeymap}
|
title="Editor keymap"
|
||||||
onChange={(editorKeymap) => callbacks.onChange({ editorKeymap })}
|
description="Keyboard shortcut preset used by text editors."
|
||||||
options={keymaps}
|
value={values.editorKeymap}
|
||||||
/>
|
onChange={(editorKeymap) => callbacks.onChange({ editorKeymap })}
|
||||||
<SettingRowBoolean
|
options={keymaps}
|
||||||
title="Wrap editor lines"
|
/>
|
||||||
description="Wrap long lines in request and response editors."
|
)}
|
||||||
checked={values.editorSoftWrap}
|
{shown("editorSoftWrap") && (
|
||||||
onChange={(editorSoftWrap) => callbacks.onChange({ editorSoftWrap })}
|
<SettingRowBoolean
|
||||||
/>
|
title="Wrap editor lines"
|
||||||
<SettingRowBoolean
|
description="Wrap long lines in request and response editors."
|
||||||
title="Colorize request methods"
|
checked={values.editorSoftWrap}
|
||||||
description="Use method-specific colors for HTTP request methods."
|
onChange={(editorSoftWrap) => callbacks.onChange({ editorSoftWrap })}
|
||||||
checked={values.coloredMethods}
|
/>
|
||||||
onChange={(coloredMethods) => callbacks.onChange({ coloredMethods })}
|
)}
|
||||||
/>
|
{shown("coloredMethods") && (
|
||||||
</SettingsSection>
|
<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">
|
{(shown("nativeTitlebar") || view.windowControlsSetting) && (
|
||||||
<NativeTitlebarSetting
|
<SettingsSection title="Window">
|
||||||
useNativeTitlebar={values.useNativeTitlebar}
|
{shown("nativeTitlebar") && (
|
||||||
onApply={callbacks.onApplyNativeTitlebar}
|
<NativeTitlebarSetting
|
||||||
/>
|
useNativeTitlebar={values.useNativeTitlebar}
|
||||||
{view.windowControlsSetting && (
|
onApply={callbacks.onApplyNativeTitlebar}
|
||||||
<SettingRowBoolean
|
/>
|
||||||
title="Hide window controls"
|
)}
|
||||||
description="Hide the close, maximize, and minimize controls on Windows or Linux."
|
{view.windowControlsSetting && (
|
||||||
checked={values.hideWindowControls}
|
<SettingRowBoolean
|
||||||
onChange={(hideWindowControls) => callbacks.onChange({ hideWindowControls })}
|
title="Hide window controls"
|
||||||
/>
|
description="Hide the close, maximize, and minimize controls on Windows or Linux."
|
||||||
)}
|
checked={values.hideWindowControls}
|
||||||
</SettingsSection>
|
onChange={(hideWindowControls) => callbacks.onChange({ hideWindowControls })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
|
||||||
{trailingSections}
|
{trailingSections}
|
||||||
</SettingsList>
|
</SettingsList>
|
||||||
|
|||||||
@@ -106,8 +106,17 @@ export interface SettingsView {
|
|||||||
feedbackPrompts: boolean;
|
feedbackPrompts: boolean;
|
||||||
/** Null when this build has no license */
|
/** Null when this build has no license */
|
||||||
license: SettingsLicenseView | null;
|
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 {
|
export interface SettingsCallbacks {
|
||||||
onChange: (patch: SettingsPatch) => void | Promise<void>;
|
onChange: (patch: SettingsPatch) => void | Promise<void>;
|
||||||
/** Save the native title bar choice and restart, since it only applies at launch */
|
/** Save the native title bar choice and restart, since it only applies at launch */
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export type TabItem =
|
|||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
|
/** Nothing in it: the label reads in the subtlest colour */
|
||||||
|
empty?: boolean;
|
||||||
leftSlot?: ReactNode;
|
leftSlot?: ReactNode;
|
||||||
rightSlot?: ReactNode;
|
rightSlot?: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -57,6 +59,8 @@ export type TabItem =
|
|||||||
export interface TabOptionsItem {
|
export interface TabOptionsItem {
|
||||||
value: string;
|
value: string;
|
||||||
options: Omit<RadioDropdownProps, "children">;
|
options: Omit<RadioDropdownProps, "children">;
|
||||||
|
/** Nothing in it: the label reads in the subtlest colour */
|
||||||
|
empty?: boolean;
|
||||||
leftSlot?: ReactNode;
|
leftSlot?: ReactNode;
|
||||||
rightSlot?: ReactNode;
|
rightSlot?: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -89,6 +93,11 @@ export interface TabsProps {
|
|||||||
activeTabKey?: string;
|
activeTabKey?: string;
|
||||||
/** Where the order and active tab under `storageKey` are kept */
|
/** Where the order and active tab under `storageKey` are kept */
|
||||||
useStoredValue?: UseStoredValue;
|
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(
|
export const Tabs = forwardRef<TabsRef, TabsProps>(function Tabs(
|
||||||
@@ -105,6 +114,7 @@ export const Tabs = forwardRef<TabsRef, TabsProps>(function Tabs(
|
|||||||
storageKey,
|
storageKey,
|
||||||
activeTabKey,
|
activeTabKey,
|
||||||
useStoredValue: useStoredValueProp,
|
useStoredValue: useStoredValueProp,
|
||||||
|
keyedDefault = false,
|
||||||
}: TabsProps,
|
}: TabsProps,
|
||||||
forwardedRef: Ref<TabsRef>,
|
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
|
// Get the active tab value - prefer storage (if activeTabKey), then defaultValue, then first tab
|
||||||
const storedActiveTab = activeTabKey ? storage?.activeTabs?.[activeTabKey] : undefined;
|
const storedActiveTab = activeTabKey ? storage?.activeTabs?.[activeTabKey] : undefined;
|
||||||
const [internalValue, setInternalValue] = useState<string | undefined>(undefined);
|
const [internal, setInternal] = useState<{ key?: string; value: string } | undefined>(undefined);
|
||||||
const value = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value;
|
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)
|
// Helper to normalize storage (handle migration from old format)
|
||||||
const normalizeStorage = useCallback(
|
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
|
// Handle tab change - update internal state, storage if we have a key, and call prop callback
|
||||||
const onChangeValue = useCallback(
|
const onChangeValue = useCallback(
|
||||||
async (newValue: string) => {
|
async (newValue: string) => {
|
||||||
setInternalValue(newValue);
|
setInternal({ key: activeTabKey, value: newValue });
|
||||||
if (storageKey && activeTabKey) {
|
if (storageKey && activeTabKey) {
|
||||||
await setStorage((s) => {
|
await setStorage((s) => {
|
||||||
const normalized = normalizeStorage(s);
|
const normalized = normalizeStorage(s);
|
||||||
@@ -501,7 +515,15 @@ function TabButton({
|
|||||||
) : "options" in tab ? (
|
) : "options" in tab ? (
|
||||||
<TabOptionsButton tab={tab} buttonProps={btnProps} isActive={isActive} />
|
<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}
|
{"label" in tab && tab.label ? tab.label : tab.value}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@@ -574,6 +596,7 @@ function TabOptionsButton({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
|
className={classNames(buttonProps.className, !isActive && tab.empty && "text-text-subtlest!")}
|
||||||
>
|
>
|
||||||
{option && "shortLabel" in option && option.shortLabel
|
{option && "shortLabel" in option && option.shortLabel
|
||||||
? option.shortLabel
|
? option.shortLabel
|
||||||
|
|||||||
Reference in new issue
Block a user