Proxy shell from the design: list, sidebar, header, empty states and instant Clear (#820)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gregory SchierandClaude Opus 5.5 authored and GitHub committed 2026-11-06 14:45:44 -07:00
1 parent c09ee5cc53
commit c629d4c66c
41 files changed
+3575 -714

No files matched your search

@@ -1,12 +1,16 @@
import { Dropdown, type DropdownItem, IconButton } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { flowTotalAtom } from "../lib/store";
import { useAtomValue, useSetAtom } from "jotai";
import { useState } from "react";
import { clearFlowsAtom, flowTotalAtom } from "../lib/store";
/** Deletes every flow, once confirmed in its menu, which its hotkey opens too */
/**
* Clears every flow, once confirmed in its menu, which its hotkey opens too. The list empties at
* once; the flows are deleted in the background.
*/
export function ClearFlowsButton() {
const total = useAtomValue(flowTotalAtom);
const clear = useRpcMutation("clear_flows");
const clear = useSetAtom(clearFlowsAtom);
const [error, setError] = useState<string | null>(null);
const items: DropdownItem[] = [
{
@@ -14,11 +18,11 @@ export function ClearFlowsButton() {
label: (
<div className="px-1.5 py-1 max-w-[17rem]">
<div className="text-sm text-text">
{total === 1 ? "Clear 1 flow?" : `Clear all ${total} flows?`}
{total === 1 ? "Clear 1 flow?" : `Clear all ${total.toLocaleString()} flows?`}
</div>
<div className="text-xs text-text-subtle mt-0.5">
{clear.error != null ? (
<span className="text-danger">{clear.error.message}</span>
{error != null ? (
<span className="text-danger">{error}</span>
) : (
"They are deleted from disk with their bodies, for good."
)}
@@ -31,7 +35,13 @@ export function ClearFlowsButton() {
label: "Clear",
icon: "trash",
color: "danger",
onSelect: () => clear.mutate({}),
onSelect: () => {
setError(null);
clear().catch((err: unknown) => {
const message = (err as { message?: unknown } | null)?.message;
setError(typeof message === "string" ? message : String(err));
});
},
},
];
+11 -7
View File
@@ -1,4 +1,4 @@
import { Button, Icon, LoadingIcon } from "@yaakapp-internal/ui";
import { Button, Icon, InlineCode, LoadingIcon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { ReactNode } from "react";
@@ -54,8 +54,8 @@ export function FirstRun() {
<div className="w-full max-w-[40rem]">
<div className="text-xl font-semibold mb-1.5">Set up Yaak Proxy</div>
<div className="text-text-subtle text-sm leading-relaxed mb-4">
Three steps, once. Afterwards every app on this Mac sends its traffic here, and HTTPS
reads as plain text.
Three steps, once. Then every app on this Mac sends its traffic here, and HTTPS reads as
plain text.
</div>
<div className="rounded-lg border border-border-subtle bg-surface divide-y divide-border-subtle">
<Step
@@ -79,7 +79,7 @@ export function FirstRun() {
color={trustState === "fail" ? "default" : "primary"}
onClick={() => trust.mutate({})}
>
{trustState === "fail" ? "Try again" : "Trust certificate"}
{trustState === "fail" ? "Try Again" : "Trust Certificate"}
</Button>
) : null
}
@@ -104,7 +104,7 @@ export function FirstRun() {
color={systemState === "fail" ? "default" : "primary"}
onClick={() => setSystemProxy.mutate({ enabled: true })}
>
{systemState === "fail" ? "Try again" : "Turn on"}
{systemState === "fail" ? "Try Again" : "Turn On"}
</Button>
) : null
}
@@ -139,7 +139,7 @@ export function FirstRun() {
color="primary"
onClick={() => execute.mutate({ scope: "global", action: "recording_toggle" })}
>
Resume recording
Resume Recording
</Button>
) : null
}
@@ -154,9 +154,13 @@ export function FirstRun() {
leftSlot={<Icon icon="square_terminal" size="sm" />}
onClick={() => void copyEnvironment()}
>
Copy environment
Copy Environment
</Button>
</div>
<div className="mt-2 text-sm text-text-subtle">
Working with an agent? <InlineCode>yaak mcp</InlineCode> gives it the proxy's flows and
actions.
</div>
</div>
</div>
);
+367 -144
View File
@@ -3,75 +3,116 @@ import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow } from "@yaakapp-internal/proxy-lib";
import {
ContextMenu,
CountBadge,
formatMillis,
HttpMethodTagRaw,
HttpStatusTagRaw,
Icon,
useContainerSize,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import type { CSSProperties, KeyboardEvent, MouseEvent, WheelEvent } from "react";
import { memo, useCallback, useLayoutEffect, useRef, useState } from "react";
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import { selectedFlowAtom, selectedFlowIdAtom } from "../lib/atoms";
import { shortContentType } from "../lib/body";
import { useFlowActions } from "../lib/flowActions";
import { utcTimestamp } from "../lib/flowViews";
import {
type Column,
type ColumnId,
gridTemplate,
hostOf,
nextSort,
shownColumns,
} from "../lib/columns";
import { useExportShownHar, useFlowActions } from "../lib/flowActions";
import { compareFlows } from "../lib/flowWindow";
import { countNewFlows } from "../lib/follow";
import { folderOf } from "../lib/search";
import {
activeQueryAtom,
followAtom,
columnPrefsAtom,
hasAppsAtom,
flowSortAtom,
followAtom,
loadOlderFlowsAtom,
newestStartedAtAtom,
oldestFlowAtom,
searchWindowAtom,
proxyStateAtom,
shownFlowsAtom,
} from "../lib/store";
import { AppIcon } from "./AppIcon";
import { SearchBar } from "./SearchBar";
import { FlowListEmpty, PausedRow } from "./FlowListEmpty";
import { FlowListHeader } from "./FlowListHeader";
const ROW_HEIGHT = 24;
const BOTTOM_PADDING = 6;
/** Rows from the top at which older flows start loading */
const LOAD_OLDER_ROWS = 20;
/** How long a row that arrives while following stays tinted */
const ARRIVAL_MS = 1400;
/** Most flows arriving together that still tint; more is a burst */
const ARRIVAL_MAX_BATCH = 5;
/** `h-xs`, the height of Yaak's event rows and menu items, in pixels at the root font size */
function rowHeight(): number {
const root = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 15;
return Math.round(1.8 * root);
}
interface Props {
sideBySide: boolean;
style?: CSSProperties;
className?: string;
}
/**
* Follows new traffic until a flow is selected or the list is scrolled, then holds. Scrolling to
* the top loads older flows from the database.
* the top loads older flows from the database. Catching up leaves a divider where the hold
* began. Sorting by a column sorts the flows loaded and stops following.
*/
export function FlowList({ sideBySide, style, className }: Props) {
export function FlowList({ style, className }: Props) {
const flows = useAtomValue(shownFlowsAtom);
const hasApps = useAtomValue(hasAppsAtom);
const [menu, setMenu] = useState<{ flow: Flow; x: number; y: number } | null>(null);
const flowActions = useFlowActions();
const exportShown = useExportShownHar();
const selectedId = useAtomValue(selectedFlowIdAtom);
const setSelectedFlow = useSetAtom(selectedFlowAtom);
const loadOlder = useSetAtom(loadOlderFlowsAtom);
const oldestFlow = useAtomValue(oldestFlowAtom);
const prefs = useAtomValue(columnPrefsAtom);
const [sort, setSort] = useAtom(flowSortAtom);
const proxyState = useAtomValue(proxyStateAtom);
const paused = proxyState?.state === "running" && proxyState.recording === false;
const containerRef = useRef<HTMLDivElement>(null);
const { width } = useContainerSize(containerRef);
const scrollRef = useRef<HTMLDivElement>(null);
const ignoreScroll = useRef(false);
const [following, setFollowing] = useAtom(followAtom);
const [heldAt, setHeldAt] = useState<string | null>(null);
/** The first flow after the last catch-up's hold, and how many came after it */
const [divider, setDivider] = useState<{ id: string; count: number } | null>(null);
// The newest flow across all traffic, not just this list, so a filter change keeps the hold
const newestStartedAt = useAtomValue(newestStartedAtAtom);
const ROW_HEIGHT = useMemo(rowHeight, []);
// Read by the callbacks below, which stay the same across new traffic so rows don't re-render
const latest = useRef({ flows, following, newestStartedAt });
const latest = useRef({ flows, following, newestStartedAt, heldAt, sort });
useLayoutEffect(() => {
latest.current = { flows, following, newestStartedAt };
latest.current = { flows, following, newestStartedAt, heldAt, sort };
});
// Where the platform cannot tell which app sent a flow, there is no App column.
const hasApps = useAtomValue(hasAppsAtom);
const columns = useMemo(
() => shownColumns(prefs, width).filter((c) => hasApps || c.id !== "app"),
[prefs, width, hasApps],
);
const template = useMemo(() => gridTemplate(columns), [columns]);
const arrivals = useArrivals(flows, following && sort == null);
const virtualizer = useVirtualizer({
count: flows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW_HEIGHT,
getItemKey: (index) => flows[index]?.id ?? index,
paddingEnd: BOTTOM_PADDING,
paddingEnd: BOTTOM_PADDING + (paused && flows.length > 0 ? ROW_HEIGHT + 8 : 0),
overscan: 40,
});
@@ -92,30 +133,44 @@ export function FlowList({ sideBySide, style, className }: Props) {
}, [setFollowing]);
const follow = useCallback(() => {
const { flows, heldAt, following, sort } = latest.current;
// Catching up from a hold marks where it began.
if (!following && sort == null) {
const count = countNewFlows(flows, heldAt);
const first = flows[flows.length - count];
setDivider(count > 0 && first != null ? { id: first.id, count } : null);
}
setSort(null);
setFollowing(true);
setHeldAt(null);
setScrollTop(Number.MAX_SAFE_INTEGER);
}, [setFollowing, setScrollTop]);
}, [setFollowing, setScrollTop, setSort]);
useLayoutEffect(() => {
if (following) setScrollTop(Number.MAX_SAFE_INTEGER);
}, [following, flows, setScrollTop]);
}, [following, flows, paused, setScrollTop]);
// A new sort starts from its top.
useLayoutEffect(() => {
if (sort != null) setScrollTop(0);
}, [sort, setScrollTop]);
// Older flows that loaded in above push the rows down; scroll by as much, so they stay put.
const anchor = useRef<Flow | null>(null);
useLayoutEffect(() => {
const previous = anchor.current;
anchor.current = oldestFlow;
if (following || previous == null || oldestFlow == null) return;
if (following || sort != null || previous == null || oldestFlow == null) return;
if (compareFlows(oldestFlow, previous) >= 0) return;
const added = flows.findIndex((f) => compareFlows(f, previous) >= 0);
const el = scrollRef.current;
if (added > 0 && el != null) setScrollTop(el.scrollTop + added * ROW_HEIGHT);
}, [oldestFlow, flows, following, setScrollTop]);
}, [oldestFlow, flows, following, sort, setScrollTop, ROW_HEIGHT]);
// Sorted, the flows loaded are what is sorted; older ones would land anywhere.
const loadOlderHeld = useCallback(() => {
hold();
void loadOlder();
if (latest.current.sort == null) void loadOlder();
}, [hold, loadOlder]);
const handleScroll = useCallback(() => {
@@ -126,14 +181,16 @@ export function FlowList({ sideBySide, style, className }: Props) {
const el = scrollRef.current;
if (el == null) return;
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 2;
if (atEnd) {
if (atEnd && latest.current.sort == null) {
setFollowing(true);
setHeldAt(null);
} else {
hold();
if (el.scrollTop < LOAD_OLDER_ROWS * ROW_HEIGHT) void loadOlder();
if (el.scrollTop < LOAD_OLDER_ROWS * ROW_HEIGHT && latest.current.sort == null) {
void loadOlder();
}
}
}, [hold, loadOlder, setFollowing]);
}, [hold, loadOlder, setFollowing, ROW_HEIGHT]);
// A list too short to scroll still loads older flows on scrolling up.
const handleWheel = useCallback(
@@ -157,11 +214,17 @@ export function FlowList({ sideBySide, style, className }: Props) {
setScrollTop(top + ROW_HEIGHT - el.clientHeight);
}
},
[hold, setSelectedFlow, setScrollTop],
[hold, setSelectedFlow, setScrollTop, ROW_HEIGHT],
);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
// ⌘↓ catches up, as the pill does.
if (e.metaKey && !e.shiftKey && !e.altKey && e.key === "ArrowDown") {
e.preventDefault();
follow();
return;
}
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
@@ -170,7 +233,7 @@ export function FlowList({ sideBySide, style, className }: Props) {
else if (index === 0 && e.key === "ArrowUp") loadOlderHeld();
else select(e.key === "ArrowDown" ? index + 1 : index - 1);
},
[flows, loadOlderHeld, select, selectedId],
[flows, follow, loadOlderHeld, select, selectedId],
);
const openMenu = useCallback(
@@ -184,39 +247,32 @@ export function FlowList({ sideBySide, style, className }: Props) {
[select],
);
const newCount = following ? 0 : countNewFlows(flows, heldAt);
const handleSort = useCallback(
(column: ColumnId) => {
hold();
setSort((prev) => nextSort(prev, column));
},
[hold, setSort],
);
const columns = sideBySide
? hasApps
? "grid-cols-[0.9rem_1rem_2.6rem_minmax(0,2fr)_minmax(0,3fr)_2.4rem_3.6rem]"
: "grid-cols-[0.9rem_2.6rem_minmax(0,2fr)_minmax(0,3fr)_2.4rem_3.6rem]"
: hasApps
? "grid-cols-[0.9rem_8.5rem_2.6rem_13rem_minmax(0,1fr)_2.4rem_5rem_4.5rem_3.8rem]"
: "grid-cols-[0.9rem_2.6rem_13rem_minmax(0,1fr)_2.4rem_5rem_4.5rem_3.8rem]";
const newCount = following || sort != null ? 0 : countNewFlows(flows, heldAt);
const dividerIndex = divider == null ? -1 : flows.findIndex((f) => f.id === divider.id);
const totalSize = virtualizer.getTotalSize();
return (
<div
ref={containerRef}
style={style}
className={classNames(className, "grid grid-rows-[auto_auto_minmax(0,1fr)] min-h-0 text-sm")}
className={classNames(className, "grid grid-rows-[auto_minmax(0,1fr)] min-h-0 text-sm")}
>
<SearchBar />
<div
className={classNames(
columns,
"grid items-center gap-x-3 h-[26px] px-1.5 mx-1.5",
"text-text-subtlest border-b border-surface-highlight",
)}
>
<span />
{hasApps && <span>{sideBySide ? "" : "App"}</span>}
<span>Method</span>
<span>Host</span>
<span>Path</span>
<span>Status</span>
{!sideBySide && <span>Type</span>}
{!sideBySide && <span className="text-right">Size</span>}
<span className="text-right">Time</span>
</div>
<FlowListHeader
columns={columns}
template={template}
prefs={prefs}
sort={sort}
onSort={handleSort}
onSortByStart={follow}
/>
<div className="relative min-h-0">
<div
ref={scrollRef}
@@ -229,9 +285,9 @@ export function FlowList({ sideBySide, style, className }: Props) {
className="group/flows h-full overflow-y-auto px-1.5 outline-hidden"
>
{flows.length === 0 ? (
<EmptyList />
<FlowListEmpty />
) : (
<div className="relative" style={{ height: virtualizer.getTotalSize() }}>
<div className="relative" style={{ height: totalSize }}>
{virtualizer.getVirtualItems().map((item) => {
const flow = flows[item.index];
if (flow == null) return null;
@@ -241,15 +297,28 @@ export function FlowList({ sideBySide, style, className }: Props) {
flow={flow}
index={item.index}
start={item.start}
height={ROW_HEIGHT}
columns={columns}
sideBySide={sideBySide}
showApp={hasApps}
template={template}
selected={flow.id === selectedId}
arrivedAt={arrivals.get(flow.id) ?? null}
onSelect={select}
onContextMenu={openMenu}
/>
);
})}
{dividerIndex > 0 && divider != null && (
<div
aria-hidden
style={{ top: dividerIndex * ROW_HEIGHT }}
className="pointer-events-none absolute inset-x-0 h-0 border-t border-border"
>
<span className="absolute -top-2 left-1 px-1 bg-surface text-2xs text-primary">
{divider.count === 1 ? "1 new" : `${divider.count} new`}
</span>
</div>
)}
{paused && <PausedRow style={{ top: flows.length * ROW_HEIGHT }} />}
</div>
)}
</div>
@@ -270,7 +339,7 @@ export function FlowList({ sideBySide, style, className }: Props) {
)}
<ContextMenu
triggerPosition={menu}
items={menu == null ? [] : flowActions(menu.flow)}
items={menu == null ? [] : [...flowActions(menu.flow), exportShown()]}
onClose={() => setMenu(null)}
/>
</div>
@@ -278,26 +347,53 @@ export function FlowList({ sideBySide, style, className }: Props) {
);
}
/** Nothing to list, or nothing a search matches */
function EmptyList() {
const query = useAtomValue(activeQueryAtom);
const search = useAtomValue(searchWindowAtom);
if (search?.loading) return null;
return (
<p className="text-text-subtlest text-sm px-3 py-3">
{query == null ? "No traffic yet" : "No flows match"}
</p>
);
/**
* When each flow that arrived while following came in, for the tint that fades over
* `ARRIVAL_MS`. Only the newest end of the list is checked, back to the first flow already
* known, so a new flow costs the same however many are held.
*/
function useArrivals(flows: Flow[], following: boolean): Map<string, number> {
const seen = useRef<{ known: Set<string> | null; arrivals: Map<string, number> }>({
known: null,
arrivals: new Map(),
});
// Worked out in the render the new flows cause, rather than in a second one after it.
return useMemo(() => {
const s = seen.current;
if (s.known == null || !following) {
s.known = new Set(flows.map((f) => f.id));
return s.arrivals;
}
const now = Date.now();
const arrived: string[] = [];
for (let i = flows.length - 1; i >= 0; i--) {
const flow = flows[i];
if (flow == null || s.known.has(flow.id)) break;
s.known.add(flow.id);
arrived.push(flow.id);
}
// Kept to the flows held, which the window trims.
if (s.known.size > flows.length * 2) s.known = new Set(flows.map((f) => f.id));
// A burst renews every row in view, so tinting it says nothing and costs a layer a row.
if (arrived.length === 0 || arrived.length > ARRIVAL_MAX_BATCH) return s.arrivals;
const next = new Map<string, number>();
for (const [id, at] of s.arrivals) if (now - at < ARRIVAL_MS) next.set(id, at);
for (const id of arrived) next.set(id, now);
s.arrivals = next;
return next;
}, [flows, following]);
}
interface RowProps {
flow: Flow;
index: number;
start: number;
columns: string;
sideBySide: boolean;
showApp: boolean;
height: number;
columns: Column[];
template: string;
selected: boolean;
/** When it arrived while following, for its tint, or null */
arrivedAt: number | null;
onSelect: (index: number) => void;
onContextMenu: (index: number, e: MouseEvent) => void;
}
@@ -306,18 +402,21 @@ const FlowRow = memo(function FlowRow({
flow,
index,
start,
height,
columns,
sideBySide,
showApp,
template,
selected,
arrivedAt,
onSelect,
onContextMenu,
}: RowProps) {
const { host, path, query } = splitUrl(flow.url);
const failed = flow.error != null;
// Failed or cancelled before any response; after the headers, the body was cut short mid-way.
const noResponse = (failed || flow.cancelled) && flow.status == null;
const type = shortContentType(flow.contentType);
const ids = columns.map((c) => c.id);
// A failure's reason, or Cancelled, takes the type and size cells together.
const spanReason = noResponse && ids.includes("type") && ids.includes("size");
const sinceArrival = arrivedAt == null ? null : Date.now() - arrivedAt;
return (
<div
@@ -327,86 +426,210 @@ const FlowRow = memo(function FlowRow({
if (e.button === 0) onSelect(index);
}}
onContextMenu={(e) => onContextMenu(index, e)}
style={{ height: ROW_HEIGHT, transform: `translateY(${start}px)` }}
style={{
height,
transform: `translateY(${start}px)`,
gridTemplateColumns: template,
// Continues the fade where it was when a row scrolls back into view.
...(sinceArrival != null && sinceArrival < ARRIVAL_MS
? { animationDelay: `-${sinceArrival}ms` }
: {}),
}}
className={classNames(
columns,
"absolute top-0 inset-x-0 grid items-center gap-x-3 px-1.5 rounded-md whitespace-nowrap",
"[&>*]:overflow-hidden [&>*]:text-ellipsis [&>*]:min-w-0",
"[&>*]:overflow-hidden [&>*]:text-ellipsis [&>*]:min-w-0 tabular-nums",
flow.cancelled && "text-text-subtlest",
selected
? "text-text bg-surface-highlight group-focus-within/flows:bg-surface-active"
: index % 2 === 1 && "bg-surface-highlight/30",
: "hover:bg-surface-highlight/50",
sinceArrival != null && sinceArrival < ARRIVAL_MS && !selected && "flow-arrived",
)}
>
<span className="flex items-center justify-center">
{flow.savedRequestId != null ? (
<Icon icon="bookmark" size="xs" color="primary" title="Saved to Yaak" />
) : failed ? (
<Icon icon="circle_alert" size="xs" color="danger" />
) : flow.cancelled ? (
<Icon icon="circle_off" size="xs" color="secondary" title="Cancelled by the app" />
) : flow.responseTruncated ? (
<Icon icon="scissors" size="xs" color="notice" title="Body cut at the capture limit" />
) : null}
</span>
{showApp && (
<span className="flex items-center gap-1.5" title={flow.clientPath ?? undefined}>
{flow.clientPath != null && <AppIcon path={flow.clientPath} />}
{!sideBySide && (
<span className="truncate text-text-subtle">
{flow.clientName ?? <span className="text-text-subtlest">--</span>}
</span>
)}
</span>
)}
<HttpMethodTagRaw method={flow.method} short colored />
<span className="text-text-subtle">{host}</span>
<span>
{path}
<span className="text-text-subtlest">{query}</span>
</span>
{flow.cancelled && flow.status == null ? (
<span className="font-mono text-text-subtlest">--</span>
) : (
<HttpStatusTagRaw status={flow.status ?? 0} short />
)}
{!sideBySide &&
(failed ? (
<span className="text-danger" title={flow.error ?? undefined}>
{flow.error}
</span>
) : flow.cancelled ? (
<span className="text-text-subtle">Cancelled</span>
) : (
<span className="text-text-subtle">
{type ?? <span className="text-text-subtlest">--</span>}
</span>
))}
{!sideBySide && (
<span
className={classNames(
"font-mono text-right",
flow.responseTruncated ? "text-notice" : "text-text-subtle",
)}
>
{noResponse ? (
<span className="text-text-subtlest">--</span>
) : (
formatSize(flow.responseSize)
)}
</span>
)}
<span className="font-mono text-right text-text-subtle">
{flow.elapsedMs == null ? "--" : formatMillis(flow.elapsedMs)}
</span>
<FlowMark flow={flow} />
{columns.map((c) => {
if (spanReason && c.id === "size") return null;
return (
<FlowCell key={c.id} column={c.id} flow={flow} span={spanReason && c.id === "type"} />
);
})}
</div>
);
});
function splitUrl(url: string): { host: string; path: string; query: string } {
try {
const u = new URL(url);
return { host: u.host, path: u.pathname, query: u.search };
} catch {
return { host: "", path: url, query: "" };
/**
* One mark per row, by a fixed priority: failed, cancelled, cut, saved. Its tooltip lists every
* state the flow has. Streaming and agent reads join it once the engine records them.
*/
function FlowMark({ flow }: { flow: Flow }) {
const open = isOpenStream(flow);
const states: string[] = [];
if (flow.error != null) states.push(`Failed: ${flow.error}`);
if (flow.cancelled) states.push("Cancelled by the app");
if (open) states.push("Streaming");
if (flow.responseTruncated) states.push("Body cut at the capture limit");
if (flow.savedRequestId != null) states.push("Saved to Yaak");
const title = states.length === 0 ? undefined : states.join("\n");
return (
<span className="flex items-center justify-center" title={title}>
{flow.error != null ? (
<Icon icon="circle_alert" size="sm" color="danger" />
) : flow.cancelled ? (
<Icon icon="circle_off" size="sm" className="text-text-subtlest" />
) : open ? (
<span aria-hidden className="w-[7px] h-[7px] rounded-full bg-info ring-3 ring-info/20" />
) : flow.responseTruncated ? (
<Icon icon="scissors" size="sm" color="notice" />
) : flow.savedRequestId != null ? (
<Icon icon="bookmark" size="sm" color="primary" className="[&_path]:fill-current" />
) : null}
</span>
);
}
const mono = "font-mono text-xs";
/** A WebSocket, event stream or gRPC call still open: written as it opens, done once it ends */
function isOpenStream(flow: Flow): boolean {
return flow.streamKind != null && flow.elapsedMs == null && flow.error == null && !flow.cancelled;
}
function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span: boolean }) {
const failed = flow.error != null;
const noResponse = (failed || flow.cancelled) && flow.status == null;
switch (column) {
case "number":
return (
<span className={classNames(mono, "text-right text-text-subtlest")}>
{flow.number || ""}
</span>
);
case "app": {
if (flow.clientPath == null) return <span />;
const folder = folderOf(flow);
const name = flow.clientName ?? flow.clientPath;
return (
<span className="flex items-center" title={folder == null ? name : `${name} in ${folder}`}>
<AppIcon path={flow.clientPath} />
</span>
);
}
case "method":
return (
<HttpMethodTagRaw
method={flow.method}
short
className={classNames(mono, !flow.cancelled && "text-text-subtle")}
/>
);
case "host":
// Cut from the front, so the part that names the service stays.
return (
<span className={classNames("rtl text-left", !flow.cancelled && "text-text-subtle")}>
<bdi>{hostOf(flow.url)}</bdi>
</span>
);
case "path": {
const { path, query } = splitPath(flow.url);
if (flow.streamKind == null) {
return (
<span>
{path}
<span className="text-text-subtlest">{query}</span>
</span>
);
}
// A stream's message count rides the end of the path, so it survives the narrow width,
// ticking while the stream is open.
return (
<span className="flex items-center">
<span className="truncate">
{path}
<span className="text-text-subtlest">{query}</span>
</span>
<CountBadge
count={flow.messageCount}
showZero
color={isOpenStream(flow) ? "info" : undefined}
className="shrink-0 !mb-0 tabular-nums"
/>
</span>
);
}
case "status":
return flow.cancelled && flow.status == null ? (
<span className={classNames(mono, "text-text-subtlest")}>--</span>
) : (
<HttpStatusTagRaw status={flow.status ?? 0} short className={mono} />
);
case "type":
return failed && noResponse ? (
<span
className={classNames("text-danger text-xs", span && "col-span-2")}
title={flow.error ?? undefined}
>
{flow.error}
</span>
) : flow.cancelled && noResponse ? (
<span className={classNames("text-xs", span && "col-span-2")}>Cancelled</span>
) : (
<span className={classNames(mono, "text-text-subtlest")}>
{shortContentType(flow.contentType) ?? "--"}
</span>
);
case "size":
return (
<span
className={classNames(
mono,
"text-right",
flow.responseTruncated ? "text-notice" : "text-text-subtle",
)}
>
{noResponse ? "--" : formatSize(flow.responseSize)}
</span>
);
case "time":
// An open stream shows how long it has been open, quietly, as its messages land.
if (isOpenStream(flow)) {
const open = Date.now() - Date.parse(utcTimestamp(flow.startedAt));
return (
<span className={classNames(mono, "text-right text-text-subtlest")}>
{Number.isFinite(open) ? formatMillis(Math.max(0, open)) : "--"}
</span>
);
}
return (
<span
className={classNames(
mono,
"text-right",
// A second or more stands out without a new colour.
flow.elapsedMs != null && flow.elapsedMs >= 1000 && !flow.cancelled
? "text-text"
: "text-text-subtle",
)}
>
{flow.elapsedMs == null ? "--" : formatMillis(flow.elapsedMs)}
</span>
);
case "remote":
return (
<span className={classNames(mono, "text-text-subtle")}>{flow.remoteAddr ?? "--"}</span>
);
case "version":
return (
<span className={classNames(mono, "text-text-subtle")}>
{flow.upstreamHttpVersion ?? flow.clientHttpVersion}
</span>
);
}
}
function splitPath(url: string): { path: string; query: string } {
try {
const u = new URL(url);
return { path: u.pathname, query: u.search };
} catch {
return { path: url, query: "" };
}
}
@@ -0,0 +1,120 @@
import { EmptyStateText, Hotkey, Icon, InlineCode } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue, useSetAtom } from "jotai";
import { useEffect, useState } from "react";
import { rpc } from "../lib/rpc";
import { parseSearch, widerQueries } from "../lib/search";
import {
hideSystemAtom,
pausedAtAtom,
proxyStateAtom,
searchTermsAtom,
searchWindowAtom,
setSearchTextAtom,
} from "../lib/store";
/** Nothing to list: no flows yet, recording paused, or a query nothing matches */
export function FlowListEmpty() {
const text = useAtomValue(searchTermsAtom);
const search = useAtomValue(searchWindowAtom);
const proxyState = useAtomValue(proxyStateAtom);
if (search?.loading) return null;
if (text.trim() !== "") return <NoResults text={text} />;
const other = proxyState?.otherOwner ?? null;
const port = proxyState?.port ?? other?.proxyPort ?? null;
const running = proxyState?.state === "running" || other != null;
const recording = proxyState?.recording !== false;
return (
<EmptyStateText wrapperClassName="p-1.5" className="not-italic flex-col gap-1 text-center px-6">
<div className="text-text text-sm">{recording ? "No flows yet" : "Recording is paused"}</div>
<div className="text-sm text-text-subtle max-w-[26rem]">
{!running
? "The proxy is not running. Retry from the menu by the state."
: !recording
? "Traffic passes through without being stored."
: `Recording on port ${port ?? "--"}. Flows appear as apps send requests.`}
</div>
</EmptyStateText>
);
}
/**
* The list's last row while recording is paused, where the next flow would appear: since when,
* and that nothing is stored.
*/
export function PausedRow({ style }: { style?: React.CSSProperties }) {
const pausedAt = useAtomValue(pausedAtAtom);
return (
<div
style={style}
className={classNames(
"absolute inset-x-0 flex items-center gap-2 h-xs px-3 mt-1",
"rounded-md border border-dashed border-border-subtle text-text-subtle text-sm",
)}
>
<Icon icon="circle_pause" size="sm" className="text-text-subtlest" />
<span className="truncate">
{pausedAt == null ? "Paused." : `Paused at ${timeOf(pausedAt)}.`} Traffic passes through
without being stored.
</span>
<Hotkey action="recording_toggle" variant="text" className="ml-auto" />
</div>
);
}
/**
* A query nothing matches: wider ones that do, each the query with one term left out, as Yaak's
* sidebar answers, with how many each would match.
*/
function NoResults({ text }: { text: string }) {
const hideSystem = useAtomValue(hideSystemAtom);
const setText = useSetAtom(setSearchTextAtom);
const [wider, setWider] = useState<{ text: string; count: number }[]>([]);
useEffect(() => {
let current = true;
const candidates = widerQueries(text);
void Promise.all(
candidates.map(async (candidate) => {
const query = parseSearch(candidate, hideSystem);
const res = await rpc("list_flows", { ...query, limit: 0 });
return { text: candidate, count: query == null ? res.total : (res.matching ?? res.total) };
}),
)
.then((found) => {
if (current) setWider(found.filter((f) => f.count > 0).slice(0, 3));
})
.catch(() => {
if (current) setWider([]);
});
return () => {
current = false;
};
}, [text, hideSystem]);
return (
<EmptyStateText
wrapperClassName="p-1.5 !h-auto"
className="not-italic !h-auto py-3 px-4 text-sm text-text-subtle"
>
<div className="text-center leading-relaxed">
No flows match.
{wider.length > 0 && " Found matches for "}
{wider.map((w, i) => (
<span key={w.text}>
{i > 0 && (i === wider.length - 1 ? ", or " : ", ")}
<button type="button" className="hover:underline" onClick={() => setText(w.text)}>
<InlineCode>{w.text === "" ? "all traffic" : w.text}</InlineCode>
</button>{" "}
({w.count.toLocaleString()})
</span>
))}
</div>
</EmptyStateText>
);
}
function timeOf(ms: number): string {
return new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}
@@ -0,0 +1,151 @@
import {
ContextMenu,
Dropdown,
type DropdownItem,
Icon,
IconButton,
ResizeHandle,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useSetAtom } from "jotai";
import { memo, type MouseEvent, useCallback, useRef, useState } from "react";
import {
type Column,
COLUMNS,
type ColumnId,
type ColumnPrefs,
type FlowSort,
resetWidths,
resizeColumn,
toggleColumn,
} from "../lib/columns";
import { useExportShownHar } from "../lib/flowActions";
import { columnPrefsAtom } from "../lib/store";
interface Props {
columns: Column[];
template: string;
prefs: ColumnPrefs;
sort: FlowSort | null;
onSort: (column: ColumnId) => void;
onSortByStart: () => void;
}
/**
* The list's only control surface: a click on a label sorts by it and a second reverses it,
* dragging the edge after a label resizes its column, and the picker sits in the empty corner on
* hover, or anywhere on a right-click.
*/
export const FlowListHeader = memo(function FlowListHeader({
columns,
template,
prefs,
sort,
onSort,
onSortByStart,
}: Props) {
const setPrefs = useSetAtom(columnPrefsAtom);
const exportShown = useExportShownHar();
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
// Widths when a drag began, so each move sets the width from where it started.
const startWidth = useRef(0);
const items: DropdownItem[] = [
{
type: "content",
label: <div className="px-1.5 pt-1 text-xs text-text-subtlest">Columns</div>,
},
...COLUMNS.map((c): DropdownItem => ({
label: c.label,
keepOpenOnSelect: true,
disabled: c.id === "path",
leftSlot:
(prefs.visible[c.id] ?? c.defaultVisible) || c.id === "path" ? (
<Icon icon="check" size="sm" />
) : (
<span aria-hidden className="inline-block w-4 h-4" />
),
rightSlot:
c.id === "path" ? <span className="text-xs text-text-subtlest">always</span> : undefined,
onSelect: () => setPrefs((p) => toggleColumn(p, c.id)),
})),
{ type: "separator" },
{ label: "Sort by Start Time", disabled: sort == null, onSelect: onSortByStart },
{ label: "Reset Column Widths", onSelect: () => setPrefs((p) => resetWidths(p)) },
{ type: "separator" },
exportShown(),
];
const openMenu = useCallback((e: MouseEvent) => {
e.preventDefault();
setMenu({ x: e.clientX, y: e.clientY });
}, []);
return (
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the same menu as the picker
<div
onContextMenu={openMenu}
style={{ gridTemplateColumns: template }}
className={classNames(
"group/header grid items-center gap-x-3 h-xs px-1.5 mx-1.5 select-none",
"text-xs text-text-subtlest border-b border-border-subtle",
)}
>
<span className="flex items-center justify-center">
<Dropdown items={items}>
<IconButton
size="xs"
icon="settings_2"
title="Columns"
iconColor="secondary"
className="opacity-0 group-hover/header:opacity-100 focus-visible:opacity-100 !h-5 !w-5"
/>
</Dropdown>
</span>
{columns.map((c) => (
<span key={c.id} className="relative h-full flex items-center min-w-0">
<button
type="button"
onClick={() => onSort(c.id)}
title={
sort?.column === c.id
? "Sorted over the loaded flows. Sort by Start Time in this menu goes back."
: `Sort by ${c.label}`
}
className={classNames(
"flex items-center gap-0.5 min-w-0 w-full h-full whitespace-nowrap hover:text-text-subtle",
c.align === "right" && "justify-end",
sort?.column === c.id && "text-text-subtle",
)}
>
{sort?.column === c.id && (
<Icon
icon="chevron_down"
size="xs"
className={classNames("shrink-0", !sort.descending && "rotate-180")}
/>
)}
<span className="truncate">{c.id === "app" ? "" : c.label}</span>
</button>
{c.id !== "path" && (
<ResizeHandle
side="right"
justify="center"
className="absolute top-0 -right-[0.45rem] opacity-0 group-hover/header:opacity-100"
onResizeStart={() => {
startWidth.current = c.current;
}}
onResizeMove={(e) =>
setPrefs((p) => resizeColumn(p, c.id, startWidth.current + e.x - e.xStart))
}
onReset={() =>
setPrefs((p) => ({ ...p, widths: { ...p.widths, [c.id]: undefined } }))
}
/>
)}
</span>
))}
<ContextMenu triggerPosition={menu} items={items} onClose={() => setMenu(null)} />
</div>
);
});
+38 -31
View File
@@ -7,26 +7,28 @@ import {
Toasts,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtom, useAtomValue } from "jotai";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useState } from "react";
import { useLocalStorage } from "react-use";
import { flowLayoutAtom } from "../lib/atoms";
import { searchWindowAtom, showFirstRunAtom, shownTotalAtom } from "../lib/store";
import { flowLayoutAtom, settingsRequestedAtom } from "../lib/atoms";
import { setupIncomplete } from "../lib/firstRun";
import { noFlowsAtom, systemProxyEverOnAtom } from "../lib/store";
import { getOsType } from "../lib/tauri";
import { ClearFlowsButton } from "./ClearFlowsButton";
import { FirstRun } from "./FirstRun";
import { FlowDetail } from "./FlowDetail";
import { FlowList } from "./FlowList";
import { ProxyStatus } from "./ProxyStatus";
import { ProxyStatus, useProxySetup } from "./ProxyStatus";
import { HelperDialog } from "./HelperDialog";
import { SaveDialog } from "./SaveDialog";
import { selectedSidebarLabelAtom, Sidebar } from "./Sidebar";
import { SearchBar } from "./SearchBar";
import { Sidebar } from "./Sidebar";
import { TakeOverBanner } from "./TakeOverBanner";
export function ProxyLayout() {
const os = getOsType();
const title = useAtomValue(selectedSidebarLabelAtom);
const [flowLayout, setFlowLayout] = useAtom(flowLayoutAtom);
const requestSettings = useSetAtom(settingsRequestedAtom);
const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar_width", 250);
const [sidebarHidden, setSidebarHidden] = useLocalStorage("sidebar_hidden", false);
const [floatingSidebarHidden, setFloatingSidebarHidden] = useLocalStorage(
@@ -52,8 +54,10 @@ export function ProxyLayout() {
interfaceScale={1}
className="x-theme-appHeader bg-surface"
>
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center w-full h-full pointer-events-none">
<div className="flex items-center pl-1 pointer-events-auto">
{/* Yaak's silhouette: the sidebar toggle and the state pill where Yaak has the toggle and
the environment, the search where it names the request, then its icons. */}
<div className="grid grid-cols-[minmax(0,1fr)_minmax(10rem,26rem)_minmax(0,1fr)] gap-2 items-center w-full h-full pointer-events-none">
<div className="flex items-center gap-1 pl-1 min-w-0 pointer-events-auto">
<IconButton
size="sm"
title="Toggle sidebar"
@@ -67,16 +71,12 @@ export function ProxyLayout() {
}
}}
/>
</div>
<div
data-tauri-drag-region
className="pointer-events-none flex items-center justify-center gap-2 text-sm px-2 min-w-0"
>
<span className="truncate">{title}</span>
<FlowCount />
</div>
<div className="flex items-center gap-1 pr-1">
<ProxyStatus />
</div>
<div className="pointer-events-auto min-w-0">
<SearchBar />
</div>
<div data-tauri-drag-region className="flex items-center justify-end gap-1 pr-1">
<ClearFlowsButton />
<IconButton
className="pointer-events-auto"
@@ -88,6 +88,14 @@ export function ProxyLayout() {
setFlowLayout((prev) => (prev === "horizontal" ? "vertical" : "horizontal"))
}
/>
<IconButton
className="pointer-events-auto"
icon="settings"
title="Settings"
size="sm"
iconColor="secondary"
onClick={() => requestSettings((n) => n + 1)}
/>
</div>
</div>
</HeaderSize>
@@ -146,29 +154,28 @@ export function ProxyLayout() {
);
}
/** Its own component, so new traffic re-renders the count rather than the whole layout */
function FlowCount() {
const count = useAtomValue(shownTotalAtom);
const searching = useAtomValue(searchWindowAtom) != null;
const noun = searching ? (count === 1 ? "match" : "matches") : count === 1 ? "flow" : "flows";
return <span className="text-text-subtlest shrink-0">{`${count} ${noun}`}</span>;
}
/**
* The flow list above the detail pane, or, while the database has no flows at all, the first-run
* card across the whole area: it is the empty state until traffic replaces it.
* The flow list beside or above the detail pane, or, while setup is incomplete and there are no
* flows, the first-run card across the whole area. Once setup is done an empty list says so
* itself, after a Clear too.
*/
function FlowsOrFirstRun({ flowLayout }: { flowLayout: SplitLayoutLayout }) {
if (useAtomValue(showFirstRunAtom)) return <FirstRun />;
const noFlows = useAtomValue(noFlowsAtom);
const { caStatus, systemProxy } = useProxySetup();
const systemProxyEverOn = useAtomValue(systemProxyEverOnAtom);
const incomplete = setupIncomplete({
trusted: caStatus.data?.trusted ?? null,
systemProxySupported: systemProxy.data?.supported ?? null,
systemProxyEverOn,
});
if (noFlows && incomplete) return <FirstRun />;
return (
<SplitLayout
storageKey="proxy_detail"
className="gap-1"
layout={flowLayout}
defaultRatio={0.55}
firstSlot={({ style, orientation }) => (
<FlowList sideBySide={orientation === "horizontal"} style={style} className="pt-1" />
)}
firstSlot={({ style }) => <FlowList style={style} className="pt-1" />}
secondSlot={({ style, orientation }) => (
<FlowDetail style={style} sideBySide={orientation === "horizontal"} />
)}
+109 -84
View File
@@ -9,7 +9,7 @@ import {
Icon,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue, useSetAtom, useStore } from "jotai";
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import type { HTMLAttributes, ReactNode, Ref } from "react";
import { useEffect, useRef } from "react";
import { useRpcEvent } from "../hooks/useRpcEvent";
@@ -17,7 +17,7 @@ import { useRpcMutation } from "../hooks/useRpcMutation";
import { useRpcQuery } from "../hooks/useRpcQuery";
import { helperDialogOpenAtom, helperRegisteredAtAtom } from "../lib/atoms";
import { environmentExports } from "../lib/env";
import { proxyStateAtom, refreshProxyState } from "../lib/store";
import { proxyStateAtom, refreshProxyState, systemProxyEverOnAtom } from "../lib/store";
import {
helperPollInterval,
helperRow,
@@ -28,9 +28,10 @@ import { onWindowFocus } from "../lib/tauri";
import { useShowToast } from "../lib/toast";
/**
* The header's state pill and its menu: recording, the system proxy, the certificate and HTTP/2. The
* engine runs for as long as the app does, so the pill shows whether flows are being stored. A
* certificate that is not trusted steps out of the menu beside the pill until it is.
* The header's state pill and its menu of four rows: the state, Pause or Resume Recording,
* Override system proxy, and the certificate. The engine runs for as long as the app does, so the
* pill shows whether flows are being stored. A certificate that is not trusted steps out of the
* menu beside the pill until it is. More… holds the rows that move to Settings until it exists.
*/
export function ProxyStatus() {
const dropdownRef = useRef<DropdownRef>(null);
@@ -64,8 +65,8 @@ export function ProxyStatus() {
title={recording ? `Recording on port ${port}` : `Paused on port ${port}`}
description={
recording
? "Listening on 127.0.0.1, and storing every flow."
: "Traffic passes through untouched, and no flows are stored."
? "Every flow is stored as it passes."
: "Traffic passes through untouched, and nothing is stored."
}
/>
) : (
@@ -93,45 +94,22 @@ export function ProxyStatus() {
/>
);
const items: DropdownItem[] = [
{ type: "content", label: stateRow },
{ type: "separator", hidden: systemProxy.data?.supported !== true },
// Until the helper is approved, every change asks for the password; said where it is felt.
const helperNotice =
systemProxy.data != null &&
systemProxy.data.backend !== "helper" &&
systemProxy.data.helper !== "enabled" &&
systemProxy.data.helper !== "unavailable";
// The rows that move to Settings, reachable here until it exists.
const moreItems: DropdownItem[] = [
{
type: "content",
hidden: systemProxy.data?.supported !== true,
label: (
<MenuRow
icon={<Icon icon="wifi" size="sm" color="secondary" />}
title="Override system proxy"
description={
<span className={classNames(systemSwitch.failed && "text-danger")}>
{systemSwitch.description}
</span>
}
action={
<KeepMenuOpen>
<Checkbox
title="Override system proxy"
hideLabel
checked={systemSwitch.checked}
// Only the process that owns the engine routes to it.
disabled={!running || other != null || setSystemProxy.isPending}
onChange={(enabled) => setSystemProxy.mutate({ enabled })}
/>
</KeepMenuOpen>
}
/>
),
},
{
type: "content",
hidden: systemProxy.data?.supported !== true,
label: (
<KeepMenuOpen className="block pl-[2.125rem] pr-1.5 pb-1.5">
<KeepMenuOpen className="block px-1.5 py-1.5">
<Checkbox
title={
<span className="text-xs text-text-subtle">Override system proxy at launch</span>
}
title={<span className="text-sm">Override system proxy at launch</span>}
checked={proxyState?.systemProxyAtLaunch ?? true}
disabled={setAtLaunch.isPending}
onChange={(enabled) => setAtLaunch.mutate({ enabled })}
@@ -152,6 +130,83 @@ export function ProxyStatus() {
/>
),
},
{
type: "content",
label: (
<MenuRow
icon={<Icon icon="zap" size="sm" color="secondary" />}
title="HTTP/2"
description="Lets browsers send many requests at once. Turn off for apps that misbehave on it. Applies to new connections."
action={
<KeepMenuOpen>
<Checkbox
title="HTTP/2"
hideLabel
checked={proxyState?.httpVersion !== "http1Only"}
// The owner's engine would not see the change until it restarts.
disabled={other != null || setHttpVersion.isPending}
onChange={(enabled) =>
setHttpVersion.mutate({ httpVersion: enabled ? "auto" : "http1Only" })
}
/>
</KeepMenuOpen>
}
/>
),
},
{ type: "separator" },
{
label: "Copy Environment for a Terminal",
icon: "square_terminal",
onSelect: copyEnvironment,
},
];
const items: DropdownItem[] = [
{ type: "content", label: stateRow },
{
label: recordingLabel(recording),
icon: recording ? "circle_pause" : "circle_play",
hidden: !running,
hotKeyAction: "recording_toggle",
hotKeyLabelOnly: true,
onSelect: () => execute.mutate({ scope: "global", action: "recording_toggle" }),
},
{ type: "separator" },
{
type: "content",
hidden: systemProxy.data?.supported !== true,
label: (
<MenuRow
icon={<Icon icon="wifi" size="sm" color="secondary" />}
title="Override system proxy"
description={
<>
<span className={classNames(systemSwitch.failed && "text-danger")}>
{systemSwitch.description}
</span>
{helperNotice && (
<span className="block text-notice">
macOS asks for your password each time until the helper is approved, under More.
</span>
)}
</>
}
action={
<KeepMenuOpen>
<Checkbox
title="Override system proxy"
hideLabel
checked={systemSwitch.checked}
// Only the process that owns the engine routes to it.
disabled={!running || other != null || setSystemProxy.isPending}
onChange={(enabled) => setSystemProxy.mutate({ enabled })}
/>
</KeepMenuOpen>
}
/>
),
},
{
type: "content",
label: caStatus.data?.trusted ? (
@@ -190,54 +245,12 @@ export function ProxyStatus() {
/>
),
},
{
type: "content",
label: (
<MenuRow
icon={<Icon icon="zap" size="sm" color="secondary" />}
title="HTTP/2"
description="Lets browsers send many requests at once. Turn off for apps that misbehave on it. Applies to new connections."
action={
<KeepMenuOpen>
<Checkbox
title="HTTP/2"
hideLabel
checked={proxyState?.httpVersion !== "http1Only"}
// The owner's engine would not see the change until it restarts.
disabled={other != null || setHttpVersion.isPending}
onChange={(enabled) =>
setHttpVersion.mutate({ httpVersion: enabled ? "auto" : "http1Only" })
}
/>
</KeepMenuOpen>
}
/>
),
},
{ type: "separator" },
{
label: "Copy Environment for a Terminal",
icon: "square_terminal",
onSelect: copyEnvironment,
},
{
label: recordingLabel(recording),
icon: recording ? "circle_pause" : "circle_play",
hidden: !running,
hotKeyAction: "recording_toggle",
hotKeyLabelOnly: true,
onSelect: () => execute.mutate({ scope: "global", action: "recording_toggle" }),
},
{ label: "More…", icon: "settings", submenu: moreItems },
];
return (
<>
{untrusted && (
<Pill color="warning" onClick={() => dropdownRef.current?.open()}>
<Icon icon="shield_off" size="xs" />
Certificate not trusted
</Pill>
)}
<Dropdown ref={dropdownRef} items={items}>
<Pill color={!running && other == null && proxyState != null ? "warning" : undefined}>
{other != null ? (
@@ -263,6 +276,12 @@ export function ProxyStatus() {
<Icon icon="chevron_down" size="xs" className="text-text-subtlest" />
</Pill>
</Dropdown>
{untrusted && (
<Pill color="warning" onClick={() => dropdownRef.current?.open()}>
<Icon icon="shield_off" size="xs" />
Certificate not trusted
</Pill>
)}
</>
);
}
@@ -300,6 +319,12 @@ export function useProxySetup() {
// The engine turns the system proxy on as it starts, and says so once the prompt is
// answered, and turns it off as it stops.
useRpcEvent("proxy_state_changed", () => void systemProxy.refetch());
// Setup counts as done once the system proxy has been on, whatever it is now.
const [everOn, setEverOn] = useAtom(systemProxyEverOnAtom);
const on = systemProxy.data?.enabled === true;
useEffect(() => {
if (on && !everOn) setEverOn(true);
}, [on, everOn, setEverOn]);
return { caStatus, trust, systemProxy, setSystemProxy };
}
+94 -76
View File
@@ -1,30 +1,36 @@
import { Icon, IconButton, PlainInput } from "@yaakapp-internal/ui";
import { useAtomValue, useSetAtom } from "jotai";
import { memo, useEffect, useRef, useState } from "react";
import { exportHar } from "../lib/flowActions";
import { searchTerms } from "../lib/search";
import { activeQueryAtom, searchTermsAtom } from "../lib/store";
import { useShowToast } from "../lib/toast";
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import { memo, type RefObject, useEffect, useRef } from "react";
import {
allTrafficTotalAtom,
searchTermsAtom,
searchTextAtom,
searchTextVersionAtom,
searchWindowAtom,
setSearchTextAtom,
shownTotalAtom,
} from "../lib/store";
/** How long typing settles before the search runs */
const SEARCH_DEBOUNCE_MS = 200;
/**
* Searches every flow in the database by URL, method, status and content type, within the
* sidebar's selection. ⌘F focuses it and Escape clears it.
* The header's search over every flow in the database, by URL, method, status and content type,
* and the `@app`, `@host` and `@path` terms the sidebar writes. Its placeholder counts the flows;
* a search's matches show inside it. ⌘F focuses it and Escape clears it.
*/
export const SearchBar = memo(function SearchBar() {
const [text, setText] = useState("");
const [text, setText] = useAtom(searchTextAtom);
const setTerms = useSetAtom(searchTermsAtom);
const query = useAtomValue(activeQueryAtom);
const showToast = useShowToast();
const setSearchText = useSetAtom(setSearchTextAtom);
const version = useAtomValue(searchTextVersionAtom);
// Read, not subscribed: `PlaceholderCount` keeps it current without re-rendering the field.
const total = useStore().get(allTrafficTotalAtom);
// PlainInput hands back its <input> as the ref
const inputRef = useRef<HTMLInputElement>(null);
// Bumped to empty the field, which only takes a value from outside when this changes
const [resetKey, setResetKey] = useState(0);
useEffect(() => {
const timer = setTimeout(() => setTerms(searchTerms(text)), SEARCH_DEBOUNCE_MS);
const timer = setTimeout(() => setTerms(text), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [text, setTerms]);
@@ -40,72 +46,84 @@ export const SearchBar = memo(function SearchBar() {
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
// The field is replaced to empty it, so focus moves to the new one.
// The field is replaced to take a value from outside, so focus moves to the new one.
const clear = () => {
setText("");
setTerms(null);
setResetKey((k) => k + 1);
setSearchText("");
requestAnimationFrame(() => inputRef.current?.focus());
};
return (
<div className="flex items-center gap-1.5 px-3 pb-1.5">
<div className="flex-1 min-w-0">
<PlainInput
ref={inputRef}
label="Search flows"
hideLabel
size="xs"
placeholder="Search flows by URL, method, status or type"
defaultValue={text}
forceUpdateKey={String(resetKey)}
onChange={setText}
onKeyDownCapture={(e) => {
if (e.key === "Escape" && text !== "") {
e.stopPropagation();
clear();
}
}}
leftSlot={
<Icon icon="search" size="sm" className="ml-2 mr-2.5 self-center text-text-subtlest" />
}
rightSlot={
text === "" ? (
<kbd className="self-center mr-2 px-1 rounded-sm border border-border-subtle font-sans text-2xs text-text-subtlest">
⌘F
</kbd>
) : (
<IconButton
size="xs"
icon="x"
title="Clear search"
iconColor="secondary"
className="self-center"
onClick={clear}
/>
)
}
/>
</div>
<IconButton
size="xs"
icon="download"
iconColor="secondary"
title={query == null ? "Export every flow as HAR" : "Export the flows shown as HAR"}
onClick={async () => {
try {
const written = await exportHar(query == null ? {} : { filter: query }, "flows.har");
if (written != null) {
showToast({
message:
written === 1 ? "Exported 1 flow as HAR" : `Exported ${written} flows as HAR`,
});
}
} catch (err) {
showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" });
}
}}
/>
</div>
<PlainInput
ref={inputRef}
label="Search flows"
hideLabel
size="sm"
className="font-mono text-xs"
placeholder={placeholderFor(total)}
defaultValue={text}
forceUpdateKey={String(version)}
onChange={setText}
onKeyDownCapture={(e) => {
if (e.key === "Escape" && text !== "") {
e.stopPropagation();
clear();
}
}}
leftSlot={
<>
<Icon icon="search" size="sm" className="ml-2 mr-1 self-center text-text-subtlest" />
<PlaceholderCount inputRef={inputRef} />
</>
}
rightSlot={
text === "" ? (
<kbd className="self-center mr-2 px-1 rounded-sm border border-border-subtle font-sans text-2xs text-text-subtlest">
⌘F
</kbd>
) : (
<div className="flex items-center gap-1 self-center">
<MatchCount />
<IconButton
size="xs"
icon="x"
title="Clear search"
iconColor="secondary"
onClick={clear}
/>
</div>
)
}
/>
);
});
/**
* Keeps the placeholder's count current as flows arrive, alone, so new traffic re-renders this
* and not the whole field
*/
function PlaceholderCount({ inputRef }: { inputRef: RefObject<HTMLInputElement | null> }) {
const total = useAtomValue(allTrafficTotalAtom);
useEffect(() => {
if (inputRef.current != null) inputRef.current.placeholder = placeholderFor(total);
}, [inputRef, total]);
return null;
}
/** "Search 4,102 flows", the title and count that sat in the header's middle */
function placeholderFor(total: number): string {
if (total === 0) return "Search flows";
return `Search ${total.toLocaleString()} ${total === 1 ? "flow" : "flows"}`;
}
/** A search's matches of all traffic, "12 of 4,102", inside the field before its x */
function MatchCount() {
const matches = useAtomValue(shownTotalAtom);
const total = useAtomValue(allTrafficTotalAtom);
const search = useAtomValue(searchWindowAtom);
if (search == null || search.loading) return null;
return (
<span className="font-mono text-2xs text-text-subtlest tabular-nums whitespace-nowrap">
{matches.toLocaleString()} of {total.toLocaleString()}
</span>
);
}
+69 -191
View File
@@ -1,31 +1,15 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
import type { TreeNode } from "@yaakapp-internal/ui";
import { selectedIdsFamily, Tree } from "@yaakapp-internal/ui";
import { atom, useAtomValue } from "jotai";
import { Icon, IconButton, Tree } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { atom, useAtom, useAtomValue } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { useCallback } from "react";
import { SIDEBAR_TREE_ID } from "../lib/atoms";
import { appKeyOf, appNodeId, sidebarPath } from "../lib/search";
import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse";
import { flowsAtom, flowTotalAtom } from "../lib/store";
import { type SidebarItem, SYSTEM_ID } from "../lib/sidebarTree";
import { hideSystemAtom, noteSidebarInput, sidebarTreeAtom } from "../lib/store";
import { AppIcon } from "./AppIcon";
/** A node in the sidebar tree: a domain, a path segment, or an app. */
export type SidebarItem = {
id: string;
label: string;
/** Flows held under this node; for the Domains folder, every flow in the database */
count: number;
/** An app's path, for its icon */
appPath?: string;
};
const sidebarTreeAtom = atom<TreeNode<SidebarItem>>((get) => {
const exchanges = get(flowsAtom);
return buildTree(exchanges, get(flowTotalAtom));
});
/** The domains and paths the user expanded, by node id, kept across launches */
/** The apps, sites, hosts and paths the user expanded, by node id, kept across launches */
const expandedAtom = atomWithStorage<Record<string, boolean>>("sidebar_expanded", {}, undefined, {
getOnInit: true,
});
@@ -33,7 +17,7 @@ const expandedAtom = atomWithStorage<Record<string, boolean>>("sidebar_expanded"
/** The other nodes' collapsed flags, for this launch, as before */
const otherCollapsedAtom = atom<CollapsedMap>({});
/** Domains and paths collapsed until the user expands them, for the tree */
/** Every app's tree, site, host and path, and System, collapsed until the user expands them */
const collapsedAtom = atom(
(get) =>
collapsedMap(folderIds(get(sidebarTreeAtom)), get(expandedAtom), get(otherCollapsedAtom)),
@@ -46,196 +30,90 @@ const collapsedAtom = atom(
},
);
/** The selected sidebar node's name, for the header */
export const selectedSidebarLabelAtom = atom((get) => {
const selectedId = get(selectedIdsFamily(SIDEBAR_TREE_ID))[0];
if (selectedId?.startsWith("domain:")) return selectedId.slice("domain:".length);
if (selectedId?.startsWith("path:")) return selectedId.slice("path:".length);
if (selectedId?.startsWith("app:")) {
const apps = get(sidebarTreeAtom).children?.find((c) => c.item.id === "apps");
return apps?.children?.find((c) => c.item.id === selectedId)?.item.label ?? "All traffic";
}
return "All traffic";
});
const nodeIdsByFlow = new WeakMap<Flow, string[]>();
/**
* The ids of the nodes a flow sits under, its domain first and then each path segment's, parsed
* once per flow rather than on every change to the list.
*/
function nodeIdsOf(ex: Flow): string[] {
let ids = nodeIdsByFlow.get(ex);
if (ids != null) return ids;
// By the rules the database filters by, so a node shows the flows it counts.
const path = sidebarPath(ex.url);
const schemeless = !ex.url.includes("://");
const [hostname = "", ...segments] = schemeless ? [path] : path.split("/");
ids = [`domain:${hostname}`];
let node = `path:${hostname}`;
for (const seg of segments) {
node += `/${seg}`;
ids.push(node);
}
nodeIdsByFlow.set(ex, ids);
return ids;
}
const collator = new Intl.Collator();
/**
* Build a domain → path-segment trie from a flat list of exchanges.
*
* Example: Given URLs
* GET https://api.example.com/v1/users
* GET https://api.example.com/v1/users/123
* POST https://api.example.com/v1/orders
*
* Produces:
* api.example.com
* /v1
* /users
* /123
* /orders
*/
function buildTree(exchanges: Flow[], total: number): TreeNode<SidebarItem> {
const root: SidebarItem = { id: "root", label: "All Traffic", count: 0 };
const rootNode: TreeNode<SidebarItem> = {
item: root,
parent: null,
depth: 0,
children: [],
draggable: false,
};
// Intermediate trie structure for building
type TrieNode = {
id: string;
label: string;
count: number;
children: Map<string, TrieNode>;
};
const domainMap = new Map<string, TrieNode>();
for (const ex of exchanges) {
const ids = nodeIdsOf(ex);
let current: TrieNode | undefined;
for (const id of ids) {
const siblings = current == null ? domainMap : current.children;
let node = siblings.get(id);
if (node == null) {
// The domain, or the path's last segment
const label = current == null ? id.slice("domain:".length) : id.slice(id.lastIndexOf("/"));
node = { id, label, count: 0, children: new Map() };
siblings.set(id, node);
}
node.count++;
current = node;
}
}
// Convert trie to TreeNode structure
function toTreeNode(
trie: TrieNode,
parent: TreeNode<SidebarItem>,
depth: number,
): TreeNode<SidebarItem> {
const node: TreeNode<SidebarItem> = {
item: {
id: trie.id,
label: trie.label,
count: trie.count,
},
parent,
depth,
children: [],
draggable: false,
};
const sortedChildren = [...trie.children.values()].sort((a, b) =>
collator.compare(a.label, b.label),
);
for (const child of sortedChildren) {
node.children?.push(toTreeNode(child, node, depth + 1));
}
return node;
}
// Add a "Domains" folder between root and domain nodes
const domainsFolder: TreeNode<SidebarItem> = {
item: { id: "domains", label: "Domains", count: total },
parent: rootNode,
depth: 1,
children: [],
draggable: false,
};
const sortedDomains = [...domainMap.values()].sort((a, b) => collator.compare(a.label, b.label));
for (const domain of sortedDomains) {
domainsFolder.children?.push(toTreeNode(domain, domainsFolder, 2));
}
// The apps flows came from, where the platform can tell; none on others, so no group.
const apps = new Map<string, SidebarItem>();
for (const ex of exchanges) {
const key = appKeyOf(ex);
if (key == null) continue;
const id = appNodeId(key);
const app = apps.get(id);
if (app == null) {
apps.set(id, { id, label: ex.clientName ?? key, count: 1, appPath: ex.clientPath ?? key });
} else {
app.count++;
}
}
if (apps.size > 0) {
const appsFolder: TreeNode<SidebarItem> = {
item: { id: "apps", label: "Apps", count: 0 },
parent: rootNode,
depth: 1,
children: [],
draggable: false,
};
const sortedApps = [...apps.values()].sort((a, b) => collator.compare(a.label, b.label));
for (const item of sortedApps) {
appsFolder.children?.push({ item, parent: appsFolder, depth: 2, draggable: false });
}
rootNode.children?.push(appsFolder);
}
rootNode.children?.push(domainsFolder);
return rootNode;
function Count({ item }: { item: SidebarItem }) {
const text = item.countLabel ?? (item.count > 0 ? item.count.toLocaleString() : null);
if (text == null && item.kind !== "all") return null;
return (
<span className="ml-auto pl-2 text-text-subtlest text-2xs tabular-nums shrink-0">
{text ?? "0"}
</span>
);
}
function ItemInner({ item }: { item: SidebarItem }) {
const count = item.count;
if (item.kind === "group") {
return (
<div className="flex items-center gap-2 w-full min-w-0 text-xs text-text-subtlest">
<span className="truncate">{item.label}</span>
<Count item={item} />
</div>
);
}
return (
<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.appPath != null && <AppIcon path={item.appPath} />}
<span className="truncate">{item.label}</span>
{count > 0 && <span className="ml-auto text-text-subtlest text-2xs shrink-0">{count}</span>}
{item.sublabel != null && (
<span className="truncate text-xs text-text-subtlest">{item.sublabel}</span>
)}
<Count item={item} />
</div>
);
}
/** Beside the row's button rather than in it, since it is a button of its own */
function ItemRightSlot({ item }: { item: SidebarItem }) {
return item.id === SYSTEM_ID ? <HideSystemToggle /> : <span aria-hidden />;
}
/** The eye on System's row, which leaves its daemons out of All traffic */
function HideSystemToggle() {
const [hidden, setHidden] = useAtom(hideSystemAtom);
return (
<IconButton
size="2xs"
icon={hidden ? "eye_closed" : "eye"}
iconColor="secondary"
title={hidden ? "Show System in All traffic" : "Hide System from All traffic"}
className={classNames(
"!h-4 !w-4 mr-1",
!hidden && "opacity-0 [.tree-item:hover_&]:opacity-100 focus-visible:opacity-100",
)}
onClick={(e) => {
e.stopPropagation();
setHidden(!hidden);
}}
/>
);
}
export function Sidebar() {
const tree = useAtomValue(sidebarTreeAtom);
const getItemKey = useCallback((item: SidebarItem) => `${item.id}:${item.count}`, []);
const treeId = SIDEBAR_TREE_ID;
const getItemKey = useCallback(
(item: SidebarItem) => `${item.id}:${item.count}:${item.countLabel ?? ""}`,
[],
);
return (
// The tree scrolls itself, in a row of bounded height, so its virtualizer mounts only the rows
// in view rather than every domain and path.
<aside className="x-theme-sidebar bg-surface h-full w-full min-w-0 grid grid-rows-[minmax(0,1fr)] border-r border-border-subtle">
// in view rather than every site, host and path.
// A click or key here marks the selection change it makes as the person's, so it writes the
// search; the tree's own upkeep does not.
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- only notes input the tree handles
<aside
onPointerDownCapture={noteSidebarInput}
onKeyDownCapture={noteSidebarInput}
className="x-theme-sidebar bg-surface h-full w-full min-w-0 grid grid-rows-[minmax(0,1fr)] border-r border-border-subtle"
>
<Tree
treeId={treeId}
treeId={SIDEBAR_TREE_ID}
collapsedAtom={collapsedAtom}
className="px-2 pt-2 pb-10"
root={tree}
getItemKey={getItemKey}
ItemInner={ItemInner}
ItemRightSlot={ItemRightSlot}
/>
</aside>
);
+3 -2
View File
@@ -1,2 +1,3 @@
// Hardcode font size for now. In the future, this could be configurable.
document.documentElement.style.fontSize = "14px";
// Yaak's default interface size, so the two apps read as one family. Later from the shared
// Interface settings.
document.documentElement.style.fontSize = "15px";
+2
View File
@@ -17,6 +17,7 @@ function body(data: string | Uint8Array): GetFlowBodyResponse {
const base: Flow = {
id: "fl_harness",
number: 1429,
createdAt: "2026-11-04T22:10:00",
updatedAt: "2026-11-04T22:10:01",
startedAt: "2026-11-04T22:10:00.000",
@@ -42,6 +43,7 @@ const base: Flow = {
clientName: "curl",
clientBundleId: null,
clientPath: "/usr/bin/curl",
clientCwd: null,
streamKind: null,
messageCount: 0,
grpcService: null,
+3
View File
@@ -17,6 +17,9 @@ export const followingAtom = atom(true);
/** When the helper was last registered, so its state is read until approval shows */
export const helperRegisteredAtAtom = atom<number | null>(null);
/** Settings was asked for, by the header's gear; the settings window opens from it once it exists */
export const settingsRequestedAtom = atom(0);
/** The dialog that walks through allowing the helper in System Settings */
export const helperDialogOpenAtom = atom(false);
+128
View File
@@ -0,0 +1,128 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
import { describe, expect, test } from "vite-plus/test";
import {
type ColumnPrefs,
defaultColumnPrefs,
gridTemplate,
nextSort,
parseColumnPrefs,
resetWidths,
resizeColumn,
shownColumns,
sortFlows,
toggleColumn,
WIDE_MIN_WIDTH,
} from "./columns";
function flow(number: number, extra: Partial<Flow> = {}): Flow {
return {
id: `fl_${number}`,
number,
url: "https://example.com/",
method: "GET",
status: 200,
error: null,
responseSize: 0,
elapsedMs: null,
contentType: null,
clientName: null,
clientBundleId: null,
clientPath: null,
clientCwd: null,
...extra,
} as Flow;
}
const ids = (prefs: ColumnPrefs, width: number) => shownColumns(prefs, width).map((c) => c.id);
describe("shownColumns", () => {
test("shows the wide set when there is room, and the narrow set when not", () => {
expect(ids(defaultColumnPrefs, WIDE_MIN_WIDTH)).toEqual([
"number",
"app",
"method",
"host",
"path",
"status",
"type",
"size",
"time",
]);
expect(ids(defaultColumnPrefs, WIDE_MIN_WIDTH - 1)).toEqual([
"app",
"method",
"host",
"path",
"status",
"time",
]);
});
test("follows the picker, and always keeps the path", () => {
let prefs = toggleColumn(defaultColumnPrefs, "remote");
prefs = toggleColumn(prefs, "type");
prefs = toggleColumn(prefs, "path");
expect(ids(prefs, 2000)).toContain("remote");
expect(ids(prefs, 2000)).not.toContain("type");
expect(ids(prefs, 2000)).toContain("path");
// Off by default, and outside the narrow set.
expect(ids(prefs, 400)).not.toContain("remote");
});
test("lays out fixed widths with the path taking the rest", () => {
const prefs = resizeColumn(defaultColumnPrefs, "host", 240.4);
const template = gridTemplate(shownColumns(prefs, 1200));
expect(template).toBe("22px 46px 18px 50px 240px minmax(80px,1fr) 46px 50px 72px 64px");
});
test("lets the host give way to the path in a narrow list", () => {
const host = (width: number) =>
shownColumns(defaultColumnPrefs, width).find((c) => c.id === "host");
expect(host(2000)?.current).toBe(190);
expect(host(500)?.current).toBe(120);
expect(host(100)?.current).toBe(60);
});
});
describe("column preferences", () => {
test("persist through storage and drop what no longer applies", () => {
let prefs = resizeColumn(defaultColumnPrefs, "host", 10);
prefs = toggleColumn(prefs, "number");
const stored = JSON.parse(JSON.stringify(prefs));
expect(parseColumnPrefs(stored)).toEqual({ visible: { number: false }, widths: { host: 60 } });
expect(
parseColumnPrefs({ visible: { gone: true, size: "yes" }, widths: { time: -1, size: 90 } }),
).toEqual({ visible: {}, widths: { size: 90 } });
expect(parseColumnPrefs(null)).toEqual(defaultColumnPrefs);
expect(resetWidths(prefs).widths).toEqual({});
});
});
describe("sorting", () => {
test("sorts numbers largest first and names from A, then reverses", () => {
expect(nextSort(null, "size")).toEqual({ column: "size", descending: true });
expect(nextSort(null, "host")).toEqual({ column: "host", descending: false });
expect(nextSort({ column: "host", descending: false }, "host")).toEqual({
column: "host",
descending: true,
});
});
test("sorts the loaded flows, those without a value last, ties in arrival order", () => {
const a = flow(1, { responseSize: 10, elapsedMs: 5, url: "https://b.com/" });
const b = flow(2, { responseSize: 300, elapsedMs: null, url: "https://a.com/" });
const c = flow(3, { responseSize: 10, elapsedMs: 50, url: "https://c.com/" });
const d = flow(4, { error: "DNS lookup failed", status: null, url: "https://a.com/" });
const flows = [a, b, c, d];
const by = (column: Parameters<typeof nextSort>[1], descending: boolean) =>
sortFlows(flows, { column, descending }).map((f) => f.number);
expect(by("size", true)).toEqual([2, 1, 3, 4]);
expect(by("size", false)).toEqual([1, 3, 2, 4]);
expect(by("time", true)).toEqual([3, 1, 2, 4]);
expect(by("host", false)).toEqual([2, 4, 1, 3]);
expect(by("number", true)).toEqual([4, 3, 2, 1]);
// Failures sort below every status.
expect(by("status", true)).toEqual([1, 2, 3, 4]);
expect(sortFlows(flows, null)).toBe(flows);
});
});
+314
View File
@@ -0,0 +1,314 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
import { appKeyOf } from "./search";
export type ColumnId =
| "number"
| "app"
| "method"
| "host"
| "path"
| "status"
| "type"
| "size"
| "time"
| "remote"
| "version";
export interface ColumnDef {
id: ColumnId;
/** In the header and the picker */
label: string;
/** Pixels, at the default interface size; the path takes what is left */
width: number;
minWidth: number;
align: "left" | "right";
/** Shown until the person hides it */
defaultVisible: boolean;
/** Kept when the list is narrow; the rest drop out below `WIDE_MIN_WIDTH` */
narrow: boolean;
}
export const COLUMNS: ColumnDef[] = [
{
id: "number",
label: "#",
width: 46,
minWidth: 30,
align: "right",
defaultVisible: true,
narrow: false,
},
{
id: "app",
label: "App",
width: 18,
minWidth: 18,
align: "left",
defaultVisible: true,
narrow: true,
},
{
id: "method",
label: "Method",
width: 50,
minWidth: 40,
align: "left",
defaultVisible: true,
narrow: true,
},
{
id: "host",
label: "Host",
width: 190,
minWidth: 60,
align: "left",
defaultVisible: true,
narrow: true,
},
{
id: "path",
label: "Path",
width: 0,
minWidth: 80,
align: "left",
defaultVisible: true,
narrow: true,
},
{
id: "status",
label: "Status",
width: 46,
minWidth: 34,
align: "left",
defaultVisible: true,
narrow: true,
},
{
id: "type",
label: "Type",
width: 50,
minWidth: 34,
align: "left",
defaultVisible: true,
narrow: false,
},
{
id: "size",
label: "Size",
width: 72,
minWidth: 44,
align: "right",
defaultVisible: true,
narrow: false,
},
{
id: "time",
label: "Time",
width: 64,
minWidth: 44,
align: "right",
defaultVisible: true,
narrow: true,
},
{
id: "remote",
label: "Remote Address",
width: 150,
minWidth: 60,
align: "left",
defaultVisible: false,
narrow: false,
},
{
id: "version",
label: "HTTP Version",
width: 70,
minWidth: 44,
align: "left",
defaultVisible: false,
narrow: false,
},
];
const DEFS = new Map(COLUMNS.map((c) => [c.id, c]));
export function columnDef(id: ColumnId): ColumnDef {
const def = DEFS.get(id);
if (def == null) throw new Error(`No column ${id}`);
return def;
}
/** Below this width the list keeps only the narrow set: what a glance needs */
export const WIDE_MIN_WIDTH = 760;
/** The mark column, first and fixed */
export const MARK_WIDTH = 22;
/** What the person chose: which columns show, and the widths they dragged, by id */
export interface ColumnPrefs {
visible: Partial<Record<ColumnId, boolean>>;
widths: Partial<Record<ColumnId, number>>;
}
export const defaultColumnPrefs: ColumnPrefs = { visible: {}, widths: {} };
export interface Column extends ColumnDef {
/** Its width now, in pixels; zero for the path, which takes the rest */
current: number;
}
/**
* The columns shown at `listWidth`, in order: the visible ones, less those outside the narrow set
* when the list is narrower than `WIDE_MIN_WIDTH`. The path always shows. Measured, not by
* layout, so a wide window side by side still gets size and type.
*/
export function shownColumns(prefs: ColumnPrefs, listWidth: number): Column[] {
const wide = listWidth >= WIDE_MIN_WIDTH;
return COLUMNS.filter((c) => {
if (c.id === "path") return true;
if (!(prefs.visible[c.id] ?? c.defaultVisible)) return false;
return wide || c.narrow;
}).map((c) => ({ ...c, current: currentWidth(prefs, c.id, listWidth) }));
}
/** The host gives way to the path in a narrow list, at most this share of its width */
const HOST_SHARE = 0.24;
function currentWidth(prefs: ColumnPrefs, id: ColumnId, listWidth: number): number {
if (id === "path") return 0;
const width = columnWidth(prefs, id);
if (id !== "host" || listWidth <= 0) return width;
return Math.max(columnDef(id).minWidth, Math.min(width, Math.round(listWidth * HOST_SHARE)));
}
export function columnWidth(prefs: ColumnPrefs, id: ColumnId): number {
const def = columnDef(id);
return Math.max(def.minWidth, Math.round(prefs.widths[id] ?? def.width));
}
/** The grid template for the header and every row */
export function gridTemplate(columns: Column[]): string {
const cells = columns.map((c) =>
c.id === "path" ? `minmax(${c.minWidth}px,1fr)` : `${c.current}px`,
);
return [`${MARK_WIDTH}px`, ...cells].join(" ");
}
export function toggleColumn(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs {
if (id === "path") return prefs;
const shown = prefs.visible[id] ?? columnDef(id).defaultVisible;
return { ...prefs, visible: { ...prefs.visible, [id]: !shown } };
}
export function resizeColumn(prefs: ColumnPrefs, id: ColumnId, width: number): ColumnPrefs {
if (id === "path") return prefs;
const w = Math.max(columnDef(id).minWidth, Math.round(width));
return { ...prefs, widths: { ...prefs.widths, [id]: w } };
}
export function resetWidths(prefs: ColumnPrefs): ColumnPrefs {
return { ...prefs, widths: {} };
}
/**
* Reads stored preferences, dropping what no longer applies: unknown ids, and values of the
* wrong type, so a change to the columns never breaks the list.
*/
export function parseColumnPrefs(value: unknown): ColumnPrefs {
const prefs: ColumnPrefs = { visible: {}, widths: {} };
if (value == null || typeof value !== "object") return prefs;
const { visible, widths } = value as Record<string, unknown>;
for (const def of COLUMNS) {
const v = (visible as Record<string, unknown> | undefined)?.[def.id];
if (typeof v === "boolean") prefs.visible[def.id] = v;
const w = (widths as Record<string, unknown> | undefined)?.[def.id];
if (typeof w === "number" && Number.isFinite(w) && w > 0) prefs.widths[def.id] = w;
}
return prefs;
}
/** A sort by a column, or by start time when null */
export interface FlowSort {
column: ColumnId;
descending: boolean;
}
/** Numbers sort largest first on the first click, names from A */
export function firstDirection(column: ColumnId): boolean {
return column === "number" || column === "size" || column === "time" || column === "status";
}
/** A click on a column's label: sort by it, or reverse the sort it already has */
export function nextSort(sort: FlowSort | null, column: ColumnId): FlowSort {
if (sort?.column === column) return { column, descending: !sort.descending };
return { column, descending: firstDirection(column) };
}
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
type Key = string | number | null;
function sortKey(flow: Flow, column: ColumnId): Key {
switch (column) {
case "number":
return flow.number;
case "app":
return appKeyOf(flow);
case "method":
return flow.method;
case "host":
return hostOf(flow.url);
case "path":
return pathOf(flow.url);
case "status":
return flow.error != null ? -1 : flow.status;
case "type":
return flow.contentType;
case "size":
return flow.error != null && flow.status == null ? null : flow.responseSize;
case "time":
return flow.elapsedMs;
case "remote":
return flow.remoteAddr;
case "version":
return flow.upstreamHttpVersion ?? flow.clientHttpVersion;
}
}
/**
* The flows in `sort`'s order, without a value last either way, then by start so equal values
* keep arrival order. Sorts the flows given, which are the ones loaded, not the whole database.
*/
export function sortFlows(flows: Flow[], sort: FlowSort | null): Flow[] {
if (sort == null) return flows;
const dir = sort.descending ? -1 : 1;
const keyed = flows.map((flow, i) => ({ flow, i, key: sortKey(flow, sort.column) }));
keyed.sort((a, b) => {
if (a.key == null || b.key == null) {
if (a.key == null && b.key == null) return a.i - b.i;
return a.key == null ? 1 : -1;
}
let c: number;
if (typeof a.key === "number" && typeof b.key === "number") c = a.key - b.key;
else c = collator.compare(String(a.key), String(b.key));
return c === 0 ? a.i - b.i : c * dir;
});
return keyed.map((k) => k.flow);
}
/** The URL's host, with its port */
export function hostOf(url: string): string {
const start = url.indexOf("://");
if (start === -1) return "";
const rest = url.slice(start + 3);
const end = rest.search(/[/?#]/);
return end === -1 ? rest : rest.slice(0, end);
}
function pathOf(url: string): string {
const start = url.indexOf("://");
if (start === -1) return url;
const rest = url.slice(start + 3);
const end = rest.search(/[/?#]/);
return end === -1 ? "/" : rest.slice(end);
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, test } from "vite-plus/test";
import { setupIncomplete } from "./firstRun";
describe("setupIncomplete", () => {
const done = { trusted: true, systemProxySupported: true, systemProxyEverOn: true };
test("is incomplete while the certificate is untrusted", () => {
expect(setupIncomplete({ ...done, trusted: false })).toBe(true);
});
test("is incomplete until the system proxy has been on once", () => {
expect(setupIncomplete({ ...done, systemProxyEverOn: false })).toBe(true);
expect(setupIncomplete(done)).toBe(false);
});
test("needs no system proxy where there is none", () => {
expect(
setupIncomplete({ ...done, systemProxySupported: false, systemProxyEverOn: false }),
).toBe(false);
expect(setupIncomplete({ ...done, systemProxySupported: false, trusted: false })).toBe(true);
});
test("waits for both to be read, so the card does not flash", () => {
expect(setupIncomplete({ ...done, trusted: null })).toBe(false);
expect(setupIncomplete({ ...done, systemProxySupported: null, trusted: false })).toBe(false);
});
});
+21
View File
@@ -0,0 +1,21 @@
/** What decides whether setup is done, as the app last read it */
export interface SetupState {
/** The certificate is trusted, or null until read */
trusted: boolean | null;
/** The system proxy can be set on this platform, or null until read */
systemProxySupported: boolean | null;
/** The system proxy has been on at some point since this install */
systemProxyEverOn: boolean;
}
/**
* The first-run card shows while setup is incomplete: the certificate untrusted, or the system
* proxy never on since this install, where there is one. Not while either is still being read,
* so the card does not flash. Once setup is done an empty list is the "no flows yet" state,
* after a Clear too.
*/
export function setupIncomplete(s: SetupState): boolean {
if (s.trusted == null || s.systemProxySupported == null) return false;
if (!s.trusted) return true;
return s.systemProxySupported && !s.systemProxyEverOn;
}
+31 -1
View File
@@ -2,11 +2,12 @@ import type { ExportHarRequest, Flow, FlowBody } from "@yaakapp-internal/proxy-l
import type { DropdownItem } from "@yaakapp-internal/ui";
import { save } from "@tauri-apps/plugin-dialog";
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import { atom, useSetAtom } from "jotai";
import { atom, useAtomValue, useSetAtom } from "jotai";
import { useCallback } from "react";
import { base64ToBytes, bodyFileName } from "./body";
import { curlBodyText, toCurl } from "./curl";
import { rpc } from "./rpc";
import { activeQueryAtom } from "./store";
import { useShowToast } from "./toast";
/** The flow the save dialog is open for */
@@ -60,6 +61,35 @@ export async function exportHar(
return rpc("export_har", { ...req, path });
}
/**
* Exporting every flow the list covers as HAR, all of them or the search's, for the list's menus:
* it acts on flows, so it lives where they are chosen.
*/
export function useExportShownHar(): () => DropdownItem {
const showToast = useShowToast();
const query = useAtomValue(activeQueryAtom);
return useCallback(
() => ({
label: query == null ? "Export All Flows as HAR" : "Export Matching Flows as HAR",
icon: "download",
onSelect: async () => {
try {
const written = await exportHar(query == null ? {} : { filter: query }, "flows.har");
if (written != null) {
showToast({
message:
written === 1 ? "Exported 1 flow as HAR" : `Exported ${written} flows as HAR`,
});
}
} catch (err) {
showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" });
}
},
}),
[query, showToast],
);
}
/** What a flow's context menu and the detail pane's menu offer */
export function useFlowActions(): (flow: Flow) => DropdownItem[] {
const showToast = useShowToast();
+2
View File
@@ -19,6 +19,7 @@ import {
const flow: Flow = {
id: "fl_1",
number: 1,
createdAt: "2026-11-04T22:10:00Z",
updatedAt: "2026-11-04T22:10:01Z",
startedAt: "2026-11-04T22:10:00Z",
@@ -50,6 +51,7 @@ const flow: Flow = {
clientName: null,
clientBundleId: null,
clientPath: null,
clientCwd: null,
streamKind: null,
messageCount: 0,
grpcService: null,
+172 -16
View File
@@ -1,6 +1,16 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
import { describe, expect, test } from "vite-plus/test";
import { matchesQuery, searchTerms, selectionQuery, sidebarPath } from "./search";
import {
appKeyOf,
formatFieldFilter,
isSystemFlow,
matchesQuery,
parseSearch,
sidebarPath,
siteOf,
tokenize,
widerQueries,
} from "./search";
function flow(url: string, extra: Partial<Flow> = {}): Flow {
return {
@@ -93,6 +103,37 @@ describe("matchesQuery", () => {
expect(from(["/Applications/Google Chrome.app"])).toEqual([]);
});
test("keeps flows from an app by name, or a program by the folder it ran in", () => {
// The flows `filters_by_app_name_folder_site_and_system` in `flows.rs` checks.
const slack = flow("https://edgeapi.slack.com/cache", {
clientName: "Slack",
clientBundleId: "com.tinyspeck.slackmacgap",
clientPath: "/Applications/Slack.app",
});
const node = flow("https://api.github.com:443/repos", {
clientName: "node",
clientPath: "/opt/homebrew/bin/node",
clientCwd: "/Users/me/src/fieldwork",
});
const daemon = flow("https://github.com/login", {
clientName: "nsurlsessiond",
clientPath: "/usr/libexec/nsurlsessiond",
clientCwd: "/",
});
const none = flow("http://notgithub.com/");
const flows = [slack, node, daemon, none];
const where = (query: Parameters<typeof matchesQuery>[1]) =>
flows.filter((f) => matchesQuery(f, query));
expect(where({ app: ["slack"] })).toEqual([slack]);
expect(where({ app: ["Fieldwork"] })).toEqual([node]);
expect(where({ app: ["/opt/homebrew/bin/node"] })).toEqual([node]);
expect(where({ under: ["*.github.com"] })).toEqual([node, daemon]);
expect(where({ under: ["api.github.com"] })).toEqual([node]);
expect(where({ system: true })).toEqual([daemon]);
expect(where({ system: false })).toEqual([slack, node, none]);
expect(flows.map(appKeyOf)).toEqual(["Slack", "fieldwork", "nsurlsessiond", null]);
});
test("combines terms with the sidebar's selection", () => {
expect(matchesQuery(stripe, { q: "post", under: ["api.stripe.com"] })).toBe(true);
expect(matchesQuery(stripe, { q: "post", under: ["api.github.com"] })).toBe(false);
@@ -118,26 +159,141 @@ describe("sidebarPath", () => {
});
});
describe("selectionQuery", () => {
test("is null for nothing selected or a node holding every flow", () => {
expect(selectionQuery([])).toBeNull();
expect(selectionQuery(["root"])).toBeNull();
expect(selectionQuery(["domain:a.com", "domains"])).toBeNull();
expect(selectionQuery(["apps"])).toBeNull();
describe("parseSearch", () => {
test("is null for text that narrows nothing", () => {
expect(parseSearch("")).toBeNull();
expect(parseSearch(" ")).toBeNull();
expect(parseSearch("OR")).toBeNull();
});
test("splits hosts and paths from apps", () => {
expect(selectionQuery(["domain:a.com", "path:b.com/v1", "app:com.apple.Safari"])).toEqual({
under: ["a.com", "b.com/v1"],
app: ["com.apple.Safari"],
test("keeps free words as terms, trimmed and collapsed", () => {
expect(parseSearch(" stripe post ")).toEqual({ q: "stripe post" });
});
test("reads apps, hosts and the paths below them", () => {
expect(parseSearch('@app:"Google Chrome" @app:Slack card')).toEqual({
q: "card",
app: ["Google Chrome", "Slack"],
});
expect(selectionQuery(["app:/usr/bin/curl"])).toEqual({ app: ["/usr/bin/curl"] });
expect(parseSearch("@app:fieldwork @host:api.stripe.com @path:/v1/payment_intents")).toEqual({
app: ["fieldwork"],
under: ["api.stripe.com/v1/payment_intents"],
});
expect(parseSearch("@host:*.github.com")).toEqual({ under: ["*.github.com"] });
expect(parseSearch("@host:github.com @path:/mountain-loop/yaak/*")).toEqual({
under: ["github.com/mountain-loop/yaak"],
});
// A path with no host matches anywhere in the URL.
expect(parseSearch("@path:/v1")).toEqual({ q: "/v1" });
// Each path stays with the host before it.
expect(parseSearch("@host:a.com @path:/x @host:b.com @path:/y")).toEqual({
under: ["a.com/x", "b.com/y"],
});
});
test("keeps each clause OR separates as a group of its own", () => {
// Two selected sidebar rows under different apps: Slack on a.com, or Chrome on b.com.
const text = "@app:Slack @host:a.com OR @app:Chrome @host:b.com";
const query = parseSearch(text);
expect(query).toEqual({
any: [
{ app: ["Slack"], under: ["a.com"] },
{ app: ["Chrome"], under: ["b.com"] },
],
});
const on = (clientName: string, host: string) =>
flow(`https://${host}/x`, { clientName, clientBundleId: `app.${clientName}` });
const match = (f: Flow) => query != null && matchesQuery(f, query);
expect(match(on("Slack", "a.com"))).toBe(true);
expect(match(on("Chrome", "b.com"))).toBe(true);
// Not every pairing of the two rows' apps and hosts.
expect(match(on("Slack", "b.com"))).toBe(false);
expect(match(on("Chrome", "a.com"))).toBe(false);
// Words bind to their clause, and hiding System applies to each group that does not ask.
expect(parseSearch("@host:a.com OR @is:system @app:trustd", true)).toEqual({
any: [
{ under: ["a.com"], system: false },
{ app: ["trustd"], system: true },
],
});
// A clause that narrows nothing takes in every flow.
expect(parseSearch("@app:Slack OR")).toEqual({ app: ["Slack"] });
expect(parseSearch("@app:Slack OR -@foo:bar")).toBeNull();
});
test("keeps or drops System, which hiding it drops unless the text asks", () => {
expect(parseSearch("@is:system")).toEqual({ system: true });
expect(parseSearch("-@is:system stripe")).toEqual({ q: "stripe", system: false });
expect(parseSearch("", true)).toEqual({ system: false });
expect(parseSearch("@is:system", true)).toEqual({ system: true });
});
test("round-trips what the sidebar writes", () => {
const text = [
formatFieldFilter("app", "Google Chrome"),
formatFieldFilter("host", "api.x.com"),
].join(" ");
expect(text).toBe('@app:"Google Chrome" @host:api.x.com');
expect(tokenize(text)).toEqual([
{ field: "app", value: "Google Chrome", negated: false },
{ field: "host", value: "api.x.com", negated: false },
]);
});
});
describe("searchTerms", () => {
test("trims and collapses the box's text, or is null without any", () => {
expect(searchTerms(" stripe post ")).toBe("stripe post");
expect(searchTerms(" ")).toBeNull();
describe("widerQueries", () => {
test("leaves out each term in turn", () => {
expect(widerQueries('@app:Slack @status:5xx "card declined"')).toEqual([
'@status:5xx "card declined"',
'@app:Slack "card declined"',
"@app:Slack @status:5xx",
]);
expect(widerQueries("@app:Slack")).toEqual([]);
});
test("widens one OR group at a time, keeping the groups", () => {
const text = "@app:Slack @host:absent.com OR @app:Chrome @host:absent.com";
expect(widerQueries(text)).toEqual([
"@host:absent.com OR @app:Chrome @host:absent.com",
"@app:Slack OR @app:Chrome @host:absent.com",
"@app:Slack @host:absent.com OR @host:absent.com",
"@app:Slack @host:absent.com OR @app:Chrome",
]);
// A group of one term is not emptied, which would take in every flow.
expect(widerQueries("@app:Slack OR @app:Chrome @host:b.com")).toEqual([
"@app:Slack OR @host:b.com",
"@app:Slack OR @app:Chrome",
]);
});
});
describe("isSystemFlow", () => {
// The cases `tells_system_daemons_apart` in `flows.rs` checks, so the two agree.
test("is a daemon outside an app, under the system's folders or by name", () => {
const sys = (clientPath: string | null, clientBundleId: string | null = null) =>
isSystemFlow({ clientPath, clientBundleId });
expect(sys("/usr/libexec/nsurlsessiond")).toBe(true);
expect(sys("/System/Library/PrivateFrameworks/X.framework/geod")).toBe(true);
expect(sys("/somewhere/else/trustd")).toBe(true);
expect(sys("/usr/bin/curl")).toBe(false);
expect(sys("/opt/homebrew/bin/node")).toBe(false);
expect(sys("/System/Applications/Music.app", "com.apple.Music")).toBe(false);
expect(
sys(
"/System/Library/Frameworks/WebKit.framework/XPCServices/com.apple.WebKit.Networking.xpc/Contents/MacOS/com.apple.WebKit.Networking",
),
).toBe(false);
expect(sys(null)).toBe(false);
});
});
describe("siteOf", () => {
test("is the registrable domain, or the host itself without one", () => {
expect(siteOf("api.github.com")).toBe("github.com");
expect(siteOf("www.example.co.uk")).toBe("example.co.uk");
expect(siteOf("api.stripe.com:8443")).toBe("stripe.com");
expect(siteOf("localhost:3000")).toBe("localhost");
expect(siteOf("149.154.175.50")).toBe("149.154.175.50");
});
});
+205 -33
View File
@@ -1,39 +1,176 @@
import type { Flow, FlowFilter } from "@yaakapp-internal/proxy-lib";
import { getDomain } from "tldts";
/**
* A search over every flow in the database: the search box's terms and the sidebar's selection,
* as `list_flows` takes them.
* A search over every flow in the database: the search box's terms, its field terms, and
* whether System's flows are hidden, as `list_flows` takes them.
*/
export type FlowQuery = Pick<FlowFilter, "q" | "under" | "app">;
export type FlowQuery = Pick<FlowFilter, "q" | "under" | "app" | "system" | "any">;
/** The sidebar node for all of an app's flows */
export function appNodeId(key: string): string {
return `app:${key}`;
}
/** What groups an app's flows: its bundle id, or its path for a process outside an app */
export function appKeyOf(flow: Flow): string | null {
return flow.clientBundleId ?? flow.clientPath;
/** A word of the search box: a free term, or `@field:value`, maybe negated with `-` */
export interface SearchToken {
field: string | null;
value: string;
negated: boolean;
}
/**
* The sidebar's selection as a query: hosts and paths under `under`, apps under `app`. Null when
* the selection holds every flow.
* The search box's words. Values may be quoted, `@app:"Google Chrome"`; `OR` and `AND` between
* words are dropped here, and [`clausesOf`] splits on `OR`.
*/
export function selectionQuery(selectedIds: string[]): FlowQuery | null {
const under: string[] = [];
const app: string[] = [];
for (const id of selectedIds) {
if (id === "root" || id === "domains" || id === "apps") return null;
if (id.startsWith("domain:")) under.push(id.slice("domain:".length));
else if (id.startsWith("path:")) under.push(id.slice("path:".length));
else if (id.startsWith("app:")) app.push(id.slice("app:".length));
export function tokenize(text: string): SearchToken[] {
return clausesOf(text).flat();
}
/**
* The search box's words in the clauses `OR` separates, each a group of terms that must all
* match: `@app:Slack @host:a.com OR @app:Chrome @host:b.com` is two. `AND` is dropped, since
* terms side by side must all match anyway.
*/
export function clausesOf(text: string): SearchToken[][] {
const clauses: SearchToken[][] = [[]];
const re = /(-)?(?:@([a-z]+):)?(?:"([^"]*)"?|(\S+))/gi;
for (const m of text.matchAll(re)) {
const [, minus, field, quoted, bare] = m;
const value = quoted ?? bare ?? "";
if (field == null && minus == null && quoted == null && value === "OR") {
clauses.push([]);
continue;
}
if (field == null && minus == null && quoted == null && value === "AND") continue;
if (field == null && value === "" && quoted == null) continue;
clauses.at(-1)?.push({ field: field?.toLowerCase() ?? null, value, negated: minus != null });
}
if (under.length === 0 && app.length === 0) return null;
return {
return clauses.filter((c) => c.length > 0);
}
/** `@field:value`, quoting a value with spaces or quotes in it */
export function formatFieldFilter(field: string, value: string, negated = false): string {
const v = /[\s"]/.test(value) || value === "" ? `"${value.replaceAll('"', "")}"` : value;
return `${negated ? "-" : ""}@${field}:${v}`;
}
/**
* The search box's text as a query, or null when it narrows nothing. Clauses `OR` separates are
* groups, each judged whole, so two sidebar rows under different apps match what each does and
* not every pairing of their apps and hosts. Within a clause, `@app` terms match any of their
* apps; `@host` terms any of their hosts, each with the `@path` terms after it; `@is:system`
* keeps only macOS's daemons and `-@is:system` drops them, as hiding System does when the
* clause says neither. Other words match the URL, the method, the status or the type.
*/
export function parseSearch(text: string, hideSystem = false): FlowQuery | null {
const clauses = clausesOf(text);
if (clauses.length <= 1) return parseClause(clauses[0] ?? [], hideSystem);
const any: FlowQuery[] = [];
for (const clause of clauses) {
const group = parseClause(clause, hideSystem);
// A clause that narrows nothing takes in every flow, so the whole does too.
if (group == null) return hideSystem ? { system: false } : null;
any.push(group);
}
return { any };
}
function parseClause(tokens: SearchToken[], hideSystem: boolean): FlowQuery | null {
const words: string[] = [];
const apps: string[] = [];
// Each host with the paths written after it.
const hosts: { host: string; paths: string[] }[] = [];
let system: boolean | undefined = hideSystem ? false : undefined;
for (const t of tokens) {
if (t.field === "app" && !t.negated && t.value !== "") apps.push(t.value);
else if (t.field === "host" && !t.negated && t.value !== "") {
hosts.push({ host: t.value.replace(/\/+$/, ""), paths: [] });
} else if (t.field === "path" && !t.negated && t.value !== "") {
const path = t.value.replace(/\/?\*$/, "").replace(/^\/+/, "");
const host = hosts.at(-1);
// A path with no host to sit under matches anywhere in the URL.
if (host == null) {
if (path !== "") words.push(`/${path}`);
} else {
host.paths.push(path);
}
} else if (t.field === "is" && t.value.toLowerCase() === "system") system = !t.negated;
else if (t.field == null && !t.negated) words.push(t.value);
// Fields and exclusions the query language will add are ignored until it lands.
}
const under: string[] = [];
for (const { host, paths } of hosts) {
if (paths.length === 0 || host.startsWith("*.")) under.push(host);
else for (const p of paths) under.push(p === "" ? host : `${host}/${p}`);
}
const q = words.join(" ").trim();
const query: FlowQuery = {
...(q === "" ? {} : { q }),
...(under.length > 0 ? { under } : {}),
...(app.length > 0 ? { app } : {}),
...(apps.length > 0 ? { app: apps } : {}),
...(system == null ? {} : { system }),
};
return Object.keys(query).length === 0 ? null : query;
}
/**
* Wider searches for one that matched nothing: the text with each of its terms left out in turn,
* without repeats. With groups `OR` separates, a term is left out of one group at a time and
* the groups kept, since each group alone is narrower still; a group left with nothing would
* take in every flow, so it is not offered.
*/
export function widerQueries(text: string): string[] {
const format = (t: SearchToken) =>
t.field == null
? `${t.negated ? "-" : ""}${/\s/.test(t.value) ? `"${t.value}"` : t.value}`
: formatFieldFilter(t.field, t.value, t.negated);
const clauses = clausesOf(text).map((c) => c.map(format));
const out = new Set<string>();
clauses.forEach((clause, i) => {
if (clause.length <= 1) return;
clause.forEach((_, j) => {
const wider = clauses.map((c, k) => (k === i ? c.filter((_, l) => l !== j) : c));
out.add(wider.map((c) => c.join(" ")).join(" OR "));
});
});
return [...out];
}
/** The folder a program outside an app ran in, which names it better than its executable */
export function folderOf(flow: Pick<Flow, "clientBundleId" | "clientCwd">): string | null {
if (flow.clientBundleId != null || flow.clientCwd == null) return null;
const name = flow.clientCwd.replace(/\/+$/, "").split("/").pop() ?? "";
return name === "" ? null : name;
}
/**
* What `@app` names a flow's app by, and what the sidebar groups its apps by: an app's name, or
* for a program outside an app the folder it ran in, else its name. Null when unknown.
*/
export function appKeyOf(flow: Flow): string | null {
return folderOf(flow) ?? flow.clientName ?? flow.clientBundleId ?? flow.clientPath;
}
const SYSTEM_DIRS = ["/System/", "/usr/libexec/", "/usr/sbin/", "/sbin/", "/Library/Apple/"];
const SYSTEM_PROGRAMS = new Set([
"akd",
"apsd",
"cloudd",
"mDNSResponder",
"nsurlsessiond",
"ocspd",
"softwareupdated",
"trustd",
]);
/**
* A flow from one of macOS's own daemons: a process outside an app bundle whose executable is
* under `/System`, `/usr/libexec` and the like, or one of a few daemons by name, but not WebKit's
* helpers, which send apps' traffic. `is_system` in `crates-proxy/yaak-proxy-lib`, which the
* database filters by, follows the same rules.
*/
export function isSystemFlow(flow: Pick<Flow, "clientBundleId" | "clientPath">): boolean {
const path = flow.clientPath;
if (path == null || flow.clientBundleId != null) return false;
const name = path.slice(path.lastIndexOf("/") + 1);
if (name.startsWith("com.apple.WebKit.")) return false;
return SYSTEM_DIRS.some((dir) => path.startsWith(dir)) || SYSTEM_PROGRAMS.has(name);
}
/**
@@ -51,13 +188,22 @@ export function matchesQuery(flow: Flow, query: FlowQuery): boolean {
if (!matched) return false;
}
if (query.under != null && !query.under.some((node) => isUnder(flow.url, node))) return false;
if (query.app != null) {
const key = appKeyOf(flow);
if (key == null || !query.app.includes(key)) return false;
if (query.app != null && !query.app.some((app) => matchesApp(flow, app))) return false;
if (query.system != null && isSystemFlow(flow) !== query.system) return false;
// One of the groups, each judged whole, as the database judges them.
if (query.any != null && query.any.length > 0) {
return query.any.some((group) => matchesQuery(flow, group));
}
return true;
}
/** By bundle id or path exactly, or by name or folder in any case */
function matchesApp(flow: Flow, app: string): boolean {
if ((flow.clientBundleId ?? flow.clientPath) === app) return true;
const lower = app.toLowerCase();
return flow.clientName?.toLowerCase() === lower || folderOf(flow)?.toLowerCase() === lower;
}
/** `404` exactly, `40` as the start of one, or a class such as `4xx` */
function matchesStatus(status: number | null, term: string): boolean {
if (status == null) return false;
@@ -70,10 +216,42 @@ function matchesStatus(status: number | null, term: string): boolean {
/** The URL is the host or path `node` names, or something below it, by the sidebar's rules */
function isUnder(url: string, node: string): boolean {
const path = sidebarPath(url);
if (node.startsWith("*.") && !node.includes("/")) {
const site = node.slice(2).toLowerCase();
const host = hostnameOf(path.split("/")[0] ?? "");
return host === site || host.endsWith(`.${site}`);
}
const want = normalizeNode(node);
return path === want || path.startsWith(`${want}/`);
}
/** A host without its port, keeping an IPv6 address's brackets */
export function hostnameOf(host: string): string {
if (host.startsWith("[")) {
const end = host.indexOf("]");
return end === -1 ? host : host.slice(0, end + 1);
}
const colon = host.lastIndexOf(":");
return colon === -1 ? host : host.slice(0, colon);
}
const sites = new Map<string, string>();
/**
* The site a host belongs to, its registrable domain by the public suffix list: `github.com` for
* `api.github.com`, `example.co.uk` for `www.example.co.uk`. An address or a name with no public
* suffix, such as `localhost`, is its own site.
*/
export function siteOf(host: string): string {
let site = sites.get(host);
if (site == null) {
const name = hostnameOf(host);
site = getDomain(name, { allowPrivateDomains: true }) ?? name;
sites.set(host, site);
}
return site;
}
/**
* Where the sidebar places a URL: its host, lowercased and without its scheme's default port,
* then its path's non-empty segments, joined by `/`, as in `api.stripe.com/v1/charges`. A URL
@@ -108,9 +286,3 @@ function normalizeNode(node: string): string {
? node.toLowerCase()
: `${node.slice(0, slash).toLowerCase()}${node.slice(slash)}`;
}
/** The search box's text as terms for `list_flows`, or null when it has none */
export function searchTerms(text: string): string | null {
const q = text.trim().replace(/\s+/g, " ");
return q === "" ? null : q;
}
+31 -15
View File
@@ -9,11 +9,11 @@ function node(id: string, children: TreeNode<{ id: string }>[] = []): TreeNode<{
const tree = node("root", [
node("domains", [
node("domain:api.example.com", [
node("host:api.example.com", [
node("path:api.example.com/v1", [node("path:api.example.com/v1/users")]),
]),
node("domain:cdn.example.com", [node("path:cdn.example.com/app.js")]),
node("domain:empty.example.com"),
node("host:cdn.example.com", [node("path:cdn.example.com/app.js")]),
node("host:empty.example.com"),
]),
]);
@@ -22,44 +22,60 @@ describe("collapsedMap", () => {
const map = collapsedMap(folderIds(tree), {}, {});
// Empty ones too: the tree collapses one before the left arrow moves to its parent.
expect(map).toEqual({
"domain:api.example.com": true,
"host:api.example.com": true,
"path:api.example.com/v1": true,
"path:api.example.com/v1/users": true,
"domain:cdn.example.com": true,
"host:cdn.example.com": true,
"path:cdn.example.com/app.js": true,
"domain:empty.example.com": true,
"host:empty.example.com": true,
});
expect(map.root).toBeUndefined();
expect(map.domains).toBeUndefined();
});
test("collapses every app, site and System, and leaves Apps open", () => {
const groups = node("root", [
node("apps", [node("app:Slack", [node("app:Slack|site:slack.com")])]),
node("domains", [node("site:example.com", [node("host:api.example.com")])]),
node("system", [node("app:trustd")]),
]);
expect(collapsedMap(folderIds(groups), {}, {})).toEqual({
"app:Slack": true,
"app:Slack|site:slack.com": true,
"site:example.com": true,
"host:api.example.com": true,
system: true,
"app:trustd": true,
});
});
test("opens what the user expanded, and a host seen later starts collapsed", () => {
const expanded = { "domain:api.example.com": true };
const expanded = { "host:api.example.com": true };
expect(collapsedMap(folderIds(tree), expanded, {})).toMatchObject({
"domain:api.example.com": false,
"host:api.example.com": false,
"path:api.example.com/v1": true,
"domain:cdn.example.com": true,
"host:cdn.example.com": true,
});
const later = node("root", [
node("domains", [node("domain:new.example.com", [node("path:new.example.com/x")])]),
node("domains", [node("host:new.example.com", [node("path:new.example.com/x")])]),
]);
expect(collapsedMap(folderIds(later), expanded, {})["domain:new.example.com"]).toBe(true);
expect(collapsedMap(folderIds(later), expanded, {})["host:new.example.com"]).toBe(true);
});
});
describe("applyCollapsed", () => {
test("keeps an expanded domain, and forgets it when collapsed again", () => {
const prev = collapsedMap(folderIds(tree), {}, {});
const opened = applyCollapsed(prev, { ...prev, "domain:api.example.com": false }, {}, {});
expect(opened.expanded).toEqual({ "domain:api.example.com": true });
const opened = applyCollapsed(prev, { ...prev, "host:api.example.com": false }, {}, {});
expect(opened.expanded).toEqual({ "host:api.example.com": true });
// Persisted, so the next launch builds the map from it.
const relaunched = collapsedMap(folderIds(tree), opened.expanded, {});
expect(relaunched["domain:api.example.com"]).toBe(false);
expect(relaunched["host:api.example.com"]).toBe(false);
const closed = applyCollapsed(
relaunched,
{ ...relaunched, "domain:api.example.com": true },
{ ...relaunched, "host:api.example.com": true },
opened.expanded,
{},
);
+11 -2
View File
@@ -3,9 +3,18 @@ import type { TreeNode } from "@yaakapp-internal/ui";
/** Flags by node id, as the tree reads them: true is collapsed, and a missing id is open */
export type CollapsedMap = Record<string, boolean>;
/** Domains and paths start collapsed; the rest, the Domains folder, starts open as before */
/**
* Every app's tree, and every site, host and path, starts collapsed, as does System; Apps and
* Domains start open.
*/
export function startsCollapsed(id: string): boolean {
return id.startsWith("domain:") || id.startsWith("path:");
return (
id === "system" ||
id.startsWith("app:") ||
id.startsWith("site:") ||
id.startsWith("host:") ||
id.startsWith("path:")
);
}
/**
+139
View File
@@ -0,0 +1,139 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
import type { TreeNode } from "@yaakapp-internal/ui";
import { describe, expect, test } from "vite-plus/test";
import { parseSearch } from "./search";
import {
buildSidebarTree,
itemsById,
queryInText,
type SidebarItem,
selectionText,
} from "./sidebarTree";
function flow(url: string, extra: Partial<Flow> = {}): Flow {
return {
url,
method: "GET",
clientName: null,
clientBundleId: null,
clientPath: null,
clientCwd: null,
...extra,
} as Flow;
}
const slack = { clientName: "Slack", clientBundleId: "com.slack", clientPath: "/A/Slack.app" };
const node = { clientName: "node", clientPath: "/bin/node", clientCwd: "/src/fieldwork" };
const daemon = { clientName: "trustd", clientPath: "/usr/libexec/trustd" };
const flows = [
flow("https://api.github.com/repos/yaak", node),
flow("https://github.com/login", node),
flow("https://edgeapi.slack.com/cache", slack),
flow("https://ocsp.apple.com/ocsp", daemon),
];
function shape(n: TreeNode<SidebarItem>, depth = 0): string[] {
const { label, sublabel, count, countLabel } = n.item;
const line = `${" ".repeat(depth)}${label}${sublabel ? ` (${sublabel})` : ""} ${countLabel ?? count}`;
return [line, ...(n.children ?? []).flatMap((c) => shape(c, depth + 1))];
}
describe("buildSidebarTree", () => {
const root = buildSidebarTree(flows, 40);
test("groups apps, programs by folder, sites and System", () => {
expect((root.children ?? []).flatMap((c) => shape(c))).toEqual([
"All traffic 40",
"Apps 3",
" node (fieldwork) 2",
" github.com 2",
" api.github.com 1",
" /repos 1",
" /yaak 1",
" github.com 1",
" /login 1",
" Slack 1",
" slack.com 1",
" edgeapi.slack.com 1",
" /cache 1",
"Domains 4 hosts",
" apple.com 1",
" ocsp.apple.com 1",
" /ocsp 1",
" github.com 2",
" api.github.com 1",
" /repos 1",
" /yaak 1",
" github.com 1",
" /login 1",
" slack.com 1",
" edgeapi.slack.com 1",
" /cache 1",
"System 1",
" trustd 1",
" apple.com 1",
" ocsp.apple.com 1",
" /ocsp 1",
]);
});
test("gives each row the query that selects its flows", () => {
const items = itemsById(root);
const query = (id: string) => items.get(id)?.query;
expect(query("all")).toBe("");
expect(query("app:fieldwork")).toBe("@app:fieldwork");
expect(query("app:fieldwork|path:api.github.com/repos")).toBe(
"@app:fieldwork @host:api.github.com @path:/repos",
);
expect(query("site:github.com")).toBe("@host:*.github.com");
expect(query("system")).toBe("@is:system");
// A daemon's rows show its flows while System is hidden from All traffic.
expect(query("app:trustd")).toBe("@is:system @app:trustd");
expect(query("app:trustd|host:ocsp.apple.com")).toBe(
"@is:system @app:trustd @host:ocsp.apple.com",
);
expect(parseSearch(query("app:trustd") ?? "", true)?.system).toBe(true);
// What each writes, read back, covers the flows the row counts.
for (const item of items.values()) {
const parsed = parseSearch(item.query);
if (parsed == null || item.kind === "group") continue;
expect(parsed, item.id).toBeTruthy();
}
});
});
describe("selection and the search text", () => {
test("selects rows under different apps as groups of their own", () => {
const items = itemsById(buildSidebarTree(flows, 4));
const pick = (id: string) => items.get(id) as SidebarItem;
const text = selectionText([
pick("app:fieldwork|host:api.github.com"),
pick("app:Slack|site:slack.com"),
]);
expect(text).toBe("@app:fieldwork @host:api.github.com OR @app:Slack @host:*.slack.com");
expect(parseSearch(text)).toEqual({
any: [
{ app: ["fieldwork"], under: ["api.github.com"] },
{ app: ["Slack"], under: ["*.slack.com"] },
],
});
});
test("writes the selected rows' queries, OR'd, or nothing for All traffic", () => {
const a = { query: "@app:Slack" } as SidebarItem;
const b = { query: "@host:*.github.com" } as SidebarItem;
const all = { query: "" } as SidebarItem;
expect(selectionText([a])).toBe("@app:Slack");
expect(selectionText([a, b])).toBe("@app:Slack OR @host:*.github.com");
expect(selectionText([a, all])).toBe("");
});
test("keeps a row selected while every term of its query is in the text", () => {
expect(queryInText("@app:Slack", "@app:Slack card")).toBe(true);
expect(queryInText("@app:slack", "@app:Slack")).toBe(true);
expect(queryInText("@app:Slack @host:x.com", "@app:Slack")).toBe(false);
expect(queryInText("", "")).toBe(true);
expect(queryInText("", "card")).toBe(false);
});
});
+263
View File
@@ -0,0 +1,263 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
import type { TreeNode } from "@yaakapp-internal/ui";
import {
appKeyOf,
folderOf,
formatFieldFilter,
isSystemFlow,
sidebarPath,
siteOf,
tokenize,
} from "./search";
export type SidebarKind = "all" | "group" | "app" | "site" | "host" | "path";
/** A row of the sidebar: All traffic, a group, an app, or a site, host or path under one */
export interface SidebarItem {
id: string;
kind: SidebarKind;
label: string;
/** Dim beside the label: a program's folder */
sublabel?: string;
/** Flows under the row, of those loaded */
count: number;
/** Shown instead of the count, as Domains says how many hosts */
countLabel?: string;
/** An app's path, for its icon */
appPath?: string;
/** What selecting the row writes into the search box; empty for all traffic */
query: string;
}
/** The ids of All traffic and the groups */
export const ALL_ID = "all";
export const APPS_ID = "apps";
export const DOMAINS_ID = "domains";
export const SYSTEM_ID = "system";
/** A row a flow sits under, with what makes it, so a rebuild only looks it up */
interface PlaceRow {
id: string;
kind: "site" | "host" | "path";
label: string;
query: string;
}
/**
* 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.
*/
interface Place {
/** What `@app` names its app by, or null when unknown */
app: string | null;
system: boolean;
rows: PlaceRow[];
appRows: PlaceRow[];
}
const places = new WeakMap<Flow, Place>();
function placeOf(flow: Flow): Place {
let place = places.get(flow);
if (place != null) return place;
const path = sidebarPath(flow.url);
const schemeless = !flow.url.includes("://");
const [host = "", ...segments] = schemeless ? [path] : path.split("/");
const site = siteOf(host);
const app = appKeyOf(flow);
const hostQuery = formatFieldFilter("host", host);
const rows: PlaceRow[] = [
{
id: `site:${site}`,
kind: "site",
label: site,
query: formatFieldFilter("host", `*.${site}`),
},
{ id: `host:${host}`, kind: "host", label: host, query: hostQuery },
];
let p = "";
for (const segment of segments) {
p += `/${segment}`;
rows.push({
id: `path:${host}${p}`,
kind: "path",
label: `/${segment}`,
query: `${hostQuery} ${formatFieldFilter("path", p)}`,
});
}
const system = isSystemFlow(flow);
const appQuery = app == null ? "" : appQueryFor(app, system);
const appRows =
app == null
? []
: rows.map((r) => ({ ...r, id: `app:${app}|${r.id}`, query: `${appQuery} ${r.query}` }));
place = { app, system, rows, appRows };
places.set(flow, place);
return place;
}
/**
* What an app's row writes. A daemon's says `@is:system` too, so selecting it shows its flows
* while System is hidden from All traffic.
*/
function appQueryFor(app: string, system: boolean): string {
const query = formatFieldFilter("app", app);
return system ? `${formatFieldFilter("is", "system")} ${query}` : query;
}
/** A node being built, its children by id */
interface Trie {
item: SidebarItem;
children: Map<string, Trie>;
}
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
/** Counts a flow under `parent`'s child `row`, making the child the first time */
function child(parent: Trie, row: PlaceRow): Trie {
let node = parent.children.get(row.id);
if (node == null) {
node = {
item: { id: row.id, kind: row.kind, label: row.label, count: 0, query: row.query },
children: new Map(),
};
parent.children.set(row.id, node);
}
node.item.count++;
return node;
}
/** Adds a flow under `parent` by where it went: its site, then its host, then its path */
function addPlace(parent: Trie, rows: PlaceRow[]) {
let node = parent;
for (const row of rows) node = child(node, row);
}
function toNodes(
tries: Iterable<Trie>,
parent: TreeNode<SidebarItem>,
sort = byLabel,
): TreeNode<SidebarItem>[] {
return [...tries].sort(sort).map((trie) => {
const node: TreeNode<SidebarItem> = {
item: trie.item,
parent,
depth: parent.depth + 1,
children: [],
draggable: false,
};
node.children = toNodes(trie.children.values(), node);
return node;
});
}
function byLabel(a: Trie, b: Trie): number {
return collator.compare(a.item.label, b.item.label);
}
function group(id: string, label: string): Trie {
return { item: { id, kind: "group", label, count: 0, query: "" }, children: new Map() };
}
/**
* The sidebar: All traffic, then Apps, each app opening to its own sites, hosts and paths, then
* Domains by site, host and path, then System, macOS's daemons, apart from the apps. A group with
* nothing in it is left out. `total` is All traffic's count: every flow it covers in the
* database.
*/
export function buildSidebarTree(flows: Flow[], total: number): TreeNode<SidebarItem> {
const root: TreeNode<SidebarItem> = {
item: { id: "root", kind: "group", label: "", count: 0, query: "" },
parent: null,
depth: 0,
children: [],
draggable: false,
};
const apps = group(APPS_ID, "Apps");
const domains = group(DOMAINS_ID, "Domains");
const system = { ...group(SYSTEM_ID, "System") };
system.item.query = formatFieldFilter("is", "system");
for (const flow of flows) {
const place = placeOf(flow);
domains.item.count++;
addPlace(domains, place.rows);
if (place.app == null) continue;
const parent = place.system ? system : apps;
const id = `app:${place.app}`;
let app = parent.children.get(id);
if (app == null) {
const folder = folderOf(flow);
const item: SidebarItem = {
id,
kind: "app",
label: flow.clientName ?? place.app,
count: 0,
query: appQueryFor(place.app, place.system),
};
if (folder != null) item.sublabel = folder;
if (flow.clientPath != null) item.appPath = flow.clientPath;
app = { item, children: new Map() };
parent.children.set(id, app);
}
app.item.count++;
parent.item.count++;
addPlace(app, place.appRows);
}
let hosts = 0;
for (const site of domains.children.values()) hosts += site.children.size;
domains.item.countLabel = hosts === 1 ? "1 host" : `${hosts} hosts`;
root.children?.push({
item: { id: ALL_ID, kind: "all", label: "All traffic", count: total, query: "" },
parent: root,
depth: 1,
draggable: false,
});
for (const g of [apps, domains, system]) {
if (g.children.size === 0) continue;
const node: TreeNode<SidebarItem> = {
item: g.item,
parent: root,
depth: 1,
children: [],
draggable: false,
};
node.children = toNodes(g.children.values(), node);
root.children?.push(node);
}
return root;
}
/** Every row's item by id, for reading a selection */
export function itemsById(root: TreeNode<SidebarItem>): Map<string, SidebarItem> {
const map = new Map<string, SidebarItem>();
const walk = (node: TreeNode<SidebarItem>) => {
map.set(node.item.id, node.item);
for (const c of node.children ?? []) walk(c);
};
walk(root);
return map;
}
/** The search text for the selected rows: their queries, OR'd; empty for All traffic */
export function selectionText(items: SidebarItem[]): string {
const queries = [...new Set(items.map((i) => i.query))];
if (queries.includes("")) return "";
return queries.join(" OR ");
}
/**
* Whether a row's query is still in the search text, every term of it among the text's: a row
* stays selected while it is, so typing after selecting keeps it.
*/
export function queryInText(query: string, text: string): boolean {
const have = new Set(tokenize(text).map(tokenKey));
const want = tokenize(query);
if (want.length === 0) return have.size === 0;
return want.every((t) => have.has(tokenKey(t)));
}
function tokenKey(t: { field: string | null; value: string; negated: boolean }): string {
return `${t.negated ? "-" : ""}${t.field ?? ""}:${t.value.toLowerCase()}`;
}
+239 -21
View File
@@ -1,7 +1,15 @@
import type { GetProxyStateResponse, ModelPayload } from "@yaakapp-internal/proxy-lib";
import type { Flow, GetProxyStateResponse, ModelPayload } from "@yaakapp-internal/proxy-lib";
import { selectedIdsFamily } from "@yaakapp-internal/ui";
import { atom, type createStore } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { followingAtom, SIDEBAR_TREE_ID, selectedFlowAtom } from "./atoms";
import {
type ColumnPrefs,
defaultColumnPrefs,
type FlowSort,
parseColumnPrefs,
sortFlows,
} from "./columns";
import {
applyChanges,
emptyWindow,
@@ -13,7 +21,16 @@ import {
windowFromPage,
} from "./flowWindow";
import { listen, rpc } from "./rpc";
import { type FlowQuery, matchesQuery, selectionQuery } from "./search";
import { type FlowQuery, matchesQuery, parseSearch } from "./search";
import {
ALL_ID,
APPS_ID,
buildSidebarTree,
DOMAINS_ID,
itemsById,
queryInText,
selectionText,
} from "./sidebarTree";
type Store = ReturnType<typeof createStore>;
@@ -42,27 +59,90 @@ export const flowTotalAtom = atom((get) => get(flowWindowAtom).total);
export const flowsLoadedAtom = atom(false);
/**
* The database has no flows and nothing narrows the list, so the first-run card shows. A boolean
* of its own, so new traffic does not re-render what reads it.
* The database has no flows and nothing narrows the list. A boolean of its own, so new traffic
* does not re-render what reads it.
*/
export const showFirstRunAtom = atom(
(get) => get(flowsLoadedAtom) && get(flowTotalAtom) === 0 && get(activeQueryAtom) == null,
export const noFlowsAtom = atom(
(get) => get(flowsLoadedAtom) && get(flowTotalAtom) === 0 && get(searchTextAtom).trim() === "",
);
/** The system proxy has been on at some point since this install, which first run waits for */
export const systemProxyEverOnAtom = atomWithStorage("system_proxy_ever_on", false, undefined, {
getOnInit: true,
});
/** Some flow held names the app that sent it, which only some platforms can tell */
export const hasAppsAtom = atom((get) => get(flowsAtom).some((f) => f.clientName != null));
/** The search box's terms, once typing settles, or null */
export const searchTermsAtom = atom<string | null>(null);
/** The search box's text as typed, which the sidebar's selection writes too */
export const searchTextAtom = atom("");
/** The search the list shows, from the search box and the sidebar, or null for all traffic */
export const activeQueryAtom = atom<FlowQuery | null>((get) => {
const q = get(searchTermsAtom);
const selection = selectionQuery(get(selectedIdsFamily(SIDEBAR_TREE_ID)));
if (q == null && selection == null) return null;
return { ...(q == null ? {} : { q }), ...selection };
/** Bumped when the text changes from outside the box, such as the sidebar, so the box takes it */
export const searchTextVersionAtom = atom(0);
/** The search box's text once typing settles */
export const searchTermsAtom = atom("");
/** System's flows are left out of All traffic, by the eye on System's row */
export const hideSystemAtom = atomWithStorage("sidebar_hide_system", false, undefined, {
getOnInit: true,
});
/** The search the list shows, from the search box's settled text, or null for all traffic */
export const activeQueryAtom = atom<FlowQuery | null>((get) =>
parseSearch(get(searchTermsAtom), get(hideSystemAtom)),
);
/** Writes `text` into the search box at once, as the sidebar does, without waiting for typing */
export const setSearchTextAtom = atom(null, (_get, set, text: string) => {
set(searchTextAtom, text);
set(searchTermsAtom, text);
set(searchTextVersionAtom, (v) => v + 1);
});
/** Flows All traffic covers in the database: every one, or all but System's while hidden */
export const allTrafficTotalAtom = atom((get) =>
get(hideSystemAtom) ? get(nonSystemTotalAtom) : get(flowTotalAtom),
);
/** Flows in the database not from System, counted while System is hidden */
export const nonSystemTotalAtom = atom(0);
/**
* The flows the sidebar counts: the window's, taken at most once a second, so a burst of traffic
* rebuilds its trees once rather than on every batch
*/
export const sidebarFlowsAtom = atom<Flow[]>([]);
/** How long the sidebar's counts may lag the list */
const SIDEBAR_MS = 1000;
/** The sidebar's rows, from the flows loaded */
export const sidebarTreeAtom = atom((get) =>
buildSidebarTree(get(sidebarFlowsAtom), get(allTrafficTotalAtom)),
);
const storedColumnPrefsAtom = atomWithStorage<unknown>(
"flow_columns",
defaultColumnPrefs,
undefined,
{
getOnInit: true,
},
);
/** Which columns the list shows and how wide, as the person left them */
export const columnPrefsAtom = atom(
(get) => parseColumnPrefs(get(storedColumnPrefsAtom)),
(get, set, update: ColumnPrefs | ((prev: ColumnPrefs) => ColumnPrefs)) => {
const prev = parseColumnPrefs(get(storedColumnPrefsAtom));
set(storedColumnPrefsAtom, typeof update === "function" ? update(prev) : update);
},
);
/** The list's sort, or null for start time, the order flows arrive in */
export const flowSortAtom = atom<FlowSort | null>(null);
/** A window onto the flows a search covers, loaded from the database as the main one is */
export interface SearchWindow {
key: string;
@@ -79,14 +159,17 @@ export const searchWindowAtom = atom<SearchWindow | null>(null);
/** The window the list shows: the search's, or all traffic's */
const shownWindowAtom = atom((get) => get(searchWindowAtom)?.win ?? get(flowWindowAtom));
/** The flows the list shows, oldest first */
export const shownFlowsAtom = atom((get) => get(shownWindowAtom).flows);
/** The flows loaded for the list, oldest first */
export const windowFlowsAtom = atom((get) => get(shownWindowAtom).flows);
/** The flows the list shows: oldest first, or in the sort's order, over those loaded */
export const shownFlowsAtom = atom((get) => sortFlows(get(windowFlowsAtom), get(flowSortAtom)));
/** Flows in the database the list covers: every one, or those the search matches */
export const shownTotalAtom = atom((get) => get(shownWindowAtom).total);
/** Start and id of the oldest flow shown, which loading older flows moves back */
export const oldestFlowAtom = atom((get) => get(shownFlowsAtom)[0] ?? null);
export const oldestFlowAtom = atom((get) => get(windowFlowsAtom)[0] ?? null);
/** Follows new traffic, dropping the older flows scrolling up loaded, or holds the list */
export const followAtom = atom(
@@ -102,6 +185,9 @@ export const followAtom = atom(
/** The engine's state, kept current from its events, and polled while another process has it */
export const proxyStateAtom = atom<GetProxyStateResponse | null>(null);
/** When recording was seen to pause, in this session; null while recording or not known */
export const pausedAtAtom = atom<number | null>(null);
/**
* Changes applied while a page of older flows loads, or null when none is loading. A slow flow
* that ends meanwhile may be older than the window, so left out of it, and missing from a page
@@ -203,6 +289,11 @@ export function syncFlows(store: Store) {
win.total === res.total ? win : { ...win, total: res.total },
);
});
if (store.get(hideSystemAtom)) {
void rpc("list_flows", { system: false, limit: 0 }).then((res) => {
if (thisLoad === loads) store.set(nonSystemTotalAtom, res.matching ?? res.total);
});
}
const search = store.get(searchWindowAtom);
if (search == null || search.loading) return;
void rpc("list_flows", { ...search.query, limit: 0 }).then((res) => {
@@ -218,12 +309,10 @@ export function syncFlows(store: Store) {
receive([payload]);
});
// Loads again rather than only emptying, for a flow written between the delete and the event.
// Loads again rather than only emptying, for a flow written between the clear and the event.
listen("flows_cleared", () => {
store.set(emptyFlowsAtom);
pending = [];
store.set(flowWindowAtom, emptyWindow);
store.set(selectedFlowAtom, null);
store.set(followingAtom, true);
load();
});
@@ -237,10 +326,14 @@ export function syncFlows(store: Store) {
seenId = res.flows[0]?.id ?? null;
loaded = true;
store.set(flowsLoadedAtom, true);
// Flows recorded before this install remembered it mean setup was done.
if (res.total > 0) store.set(systemProxyEverOnAtom, true);
flush();
recount();
});
loadSearch(store.get(activeQueryAtom));
}
reloadFlows = load;
/** Loads the first page of `query`'s flows into a window of its own, or drops it for null */
function loadSearch(query: FlowQuery | null) {
@@ -272,6 +365,29 @@ export function syncFlows(store: Store) {
});
}
// The sidebar takes the window's flows at once when it empties or first fills, and at most
// once a second otherwise.
let sidebarTimer: ReturnType<typeof setTimeout> | null = null;
const takeSidebarFlows = () => {
sidebarTimer = null;
store.set(sidebarFlowsAtom, store.get(flowsAtom));
};
store.sub(flowsAtom, () => {
const now = store.get(flowsAtom).length;
const before = store.get(sidebarFlowsAtom).length;
if (now === 0 || before === 0) {
if (sidebarTimer != null) clearTimeout(sidebarTimer);
takeSidebarFlows();
} else if (sidebarTimer == null) {
sidebarTimer = setTimeout(takeSidebarFlows, SIDEBAR_MS);
}
});
// All traffic's count leaves System out while it is hidden.
store.sub(hideSystemAtom, () => {
if (loaded) recount();
});
let activeKey = JSON.stringify(store.get(activeQueryAtom));
store.sub(activeQueryAtom, () => {
const query = store.get(activeQueryAtom);
@@ -333,11 +449,113 @@ export function syncFlows(store: Store) {
load();
}
/** Loads the newest flows again, once `syncFlows` has started */
let reloadFlows: (() => void) | null = null;
/** Empties the list, the search's too, and their counts, as a clear leaves them */
const emptyFlowsAtom = atom(null, (_get, set) => {
set(flowWindowAtom, emptyWindow);
set(nonSystemTotalAtom, 0);
set(searchWindowAtom, (s) => (s == null ? s : { ...s, win: emptyWindow, buffered: [] }));
set(selectedFlowAtom, null);
set(followingAtom, true);
set(flowSortAtom, null);
});
/**
* Clears every flow. The list and its counts empty at once; the database marks them cleared in a
* moment and deletes them in the background, and its event reloads what came since.
*/
export const clearFlowsAtom = atom(null, async (_get, set) => {
set(emptyFlowsAtom);
try {
await rpc("clear_flows", {});
} catch (err) {
// Nothing was cleared: show the flows again.
reloadFlows?.();
throw err;
}
});
/** When the person last clicked or pressed a key in the sidebar */
let lastSidebarInput = 0;
/** How soon after a click or key a change of the sidebar's selection counts as theirs */
const SIDEBAR_INPUT_MS = 500;
/** Notes a click or key in the sidebar, so the selection change it makes writes the search */
export function noteSidebarInput() {
lastSidebarInput = Date.now();
}
/**
* Keeps the sidebar's selection and the search box in step. Selecting rows writes their query
* into the box; typing keeps a row selected while its query is still one of the box's terms,
* and All traffic selected while the box is empty.
*/
export function syncSidebarSelection(store: Store) {
const selectedAtom = selectedIdsFamily(SIDEBAR_TREE_ID);
let syncing = false;
// The tree writes its selection again as it renders, the same rows in a new array, or its last
// row when the selection was emptied; only a change of rows right after a click or a key in
// the sidebar is the person selecting.
let lastRows = store.get(selectedAtom).join("\n");
store.sub(selectedAtom, () => {
const ids = store.get(selectedAtom);
const key = ids.join("\n");
if (key === lastRows) return;
lastRows = key;
if (syncing || Date.now() - lastSidebarInput > SIDEBAR_INPUT_MS) return;
// Group headings are not filters.
const rows = ids.filter((id) => id !== APPS_ID && id !== DOMAINS_ID);
if (rows.length === 0) return;
const items = itemsById(store.get(sidebarTreeAtom));
const selected = rows.flatMap((id) => {
const item = items.get(id);
return item == null ? [] : [item];
});
if (selected.length === 0) return;
const text = selectionText(selected);
if (text === store.get(searchTextAtom)) return;
syncing = true;
store.set(setSearchTextAtom, text);
syncing = false;
});
store.sub(searchTextAtom, () => {
if (syncing) return;
const text = store.get(searchTextAtom);
const items = itemsById(store.get(sidebarTreeAtom));
const ids = store.get(selectedAtom);
const kept = ids.filter((id) => {
const item = items.get(id);
return item != null && item.query !== "" && queryInText(item.query, text);
});
const next = text.trim() === "" ? [ALL_ID] : kept;
if (next.length === ids.length && next.every((id, i) => id === ids[i])) return;
syncing = true;
store.set(selectedAtom, next);
syncing = false;
});
if (store.get(selectedAtom).length === 0) store.set(selectedAtom, [ALL_ID]);
}
/**
* Keeps `proxyStateAtom` current: on each state change, and polled while the engine is not
* running here, so another process taking it or letting it go shows.
*/
export function syncProxyState(store: Store) {
let wasRecording: boolean | null = null;
store.sub(proxyStateAtom, () => {
const recording = store.get(proxyStateAtom)?.recording ?? null;
if (recording === wasRecording) return;
// Only a pause seen here has a time; one from before launch does not.
if (recording === false && wasRecording === true) store.set(pausedAtAtom, Date.now());
if (recording !== false) store.set(pausedAtAtom, null);
wasRecording = recording;
});
function refresh() {
rpc("get_proxy_state", {})
.then((state) => store.set(proxyStateAtom, state))
+24 -1
View File
@@ -12,7 +12,7 @@
:root {
--font-family-interface: "";
--font-family-editor: "";
--editor-font-size: 12px;
--editor-font-size: 13px;
}
:root {
@@ -84,6 +84,29 @@
direction: rtl;
}
/* A flow that arrives while following tints and fades, so arrival shows without motion. An
opacity fade on a layer of its own, which the compositor runs off the main thread. */
.flow-arrived::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background-color: color-mix(in srgb, var(--primary) 12%, transparent);
will-change: opacity;
animation: flow-arrived 1.4s ease-out both;
animation-delay: inherit;
}
@keyframes flow-arrived {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
:root {
color-scheme: light dark;
--transition-duration: 100ms ease-in-out;
+2 -1
View File
@@ -6,7 +6,7 @@ import { type ReactNode, StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ProxyLayout } from "./components/ProxyLayout";
import { useHotkeyContextValue } from "./lib/hotkeys";
import { syncFlows, syncProxyState } from "./lib/store";
import { syncFlows, syncProxyState, syncSidebarSelection } from "./lib/store";
import "./main.css";
const queryClient = new QueryClient();
@@ -14,6 +14,7 @@ const jotaiStore = createStore();
syncProxyState(jotaiStore);
syncFlows(jotaiStore);
syncSidebarSelection(jotaiStore);
function Hotkeys({ children }: { children: ReactNode }) {
return <HotkeyProvider value={useHotkeyContextValue()}>{children}</HotkeyProvider>;
+1
View File
@@ -23,6 +23,7 @@
"motion": "^14.8.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"tldts": "^7.4.19",
"vkbeautify": "^0.99.3"
},
"devDependencies": {
+11 -1
View File
@@ -4,7 +4,12 @@ import type { ModelChangeEvent } from "./ModelChangeEvent";
/**
* One proxied request and its response. The bodies are in `blobs.sqlite`, not the row.
*/
export type Flow = { id: string, createdAt: string, updatedAt: string, startedAt: string, method: string, url: string,
export type Flow = { id: string,
/**
* The flow's short handle, `#1429`: its rowid, which grows as flows are written and is kept
* across updates. Zero when read without it.
*/
number: number, createdAt: string, updatedAt: string, startedAt: string, method: string, url: string,
/**
* Version spoken by the client.
*/
@@ -66,6 +71,11 @@ clientBundleId: string | null,
* Its app bundle's path, or its executable's outside an app.
*/
clientPath: string | null,
/**
* The working directory of a process outside an app, whose folder names it: `node` in
* `fieldwork`.
*/
clientCwd: string | null,
/**
* The flow carries a stream of messages, listed with `list_messages`. It is written as the
* stream opens and again as messages land; it is open until `elapsedMs`, `error` or
+22 -4
View File
@@ -52,7 +52,11 @@ bundleId: string | null,
/**
* The outermost `.app` bundle's path, or the executable's path when there is no bundle.
*/
path: string, };
path: string,
/**
* The working directory of a process outside an app, as it was when first looked up.
*/
cwd: string | null, };
export type DecodeGrpcMessageRequest = { flowId: string, seq: number, };
@@ -122,14 +126,28 @@ q?: string,
host?: string,
/**
* Only flows under one of these hosts or paths, as the app's sidebar names them:
* `api.stripe.com` or `api.stripe.com/v1/charges`, with any port after the host.
* `api.stripe.com` or `api.stripe.com/v1/charges`, with any port after the host. A host
* written `*.github.com` is a site: `github.com` and every host below it.
*/
under?: Array<string>,
/**
* Only flows from one of these apps, by bundle id, or by path for a process outside an
* app bundle.
* Only flows from one of these apps: by bundle id, by path for a process outside an app
* bundle, by name in any case (`slack`), or, for a process outside an app, by the folder
* it ran in (`fieldwork`).
*/
app?: Array<string>,
/**
* Only flows from macOS's own daemons (true), or none of them (false), as [`is_system`]
* tells them by their executable.
*/
system?: boolean,
/**
* Only flows one of these groups covers, each judged on its own: several sidebar rows
* selected, `@app:Slack @host:a.com OR @app:Chrome @host:b.com`, are two groups and not every
* pairing of their apps and hosts. A group's `after`, `before` and `limit` do not apply. Not
* carried by a query string.
*/
any?: Array<FlowFilter>,
/**
* The method, any case.
*/
@@ -0,0 +1,10 @@
-- The working directory of a program outside an app, which names it better than its executable.
ALTER TABLE flows ADD COLUMN client_cwd TEXT;
-- Flows at or below this rowid are cleared: no listing or count sees them, and they are deleted
-- in the background, in batches. The row goes once they all are.
CREATE TABLE flow_clear_mark
(
id INTEGER PRIMARY KEY CHECK (id = 1),
max_rowid INTEGER NOT NULL
);
@@ -0,0 +1,4 @@
-- One process deletes cleared flows at a time: `cleaner` names it, and `cleaner_at` (unix
-- milliseconds) is when it last went on, so another takes over once that is long past.
ALTER TABLE flow_clear_mark ADD COLUMN cleaner TEXT;
ALTER TABLE flow_clear_mark ADD COLUMN cleaner_at INTEGER;
+50 -3
View File
@@ -21,6 +21,8 @@ pub struct ClientApp {
pub bundle_id: Option<String>,
/// The outermost `.app` bundle's path, or the executable's path when there is no bundle.
pub path: String,
/// The working directory of a process outside an app, as it was when first looked up.
pub cwd: Option<String>,
}
/// Finds the app behind each client connection, once per connection, off the caller's thread.
@@ -379,7 +381,14 @@ mod imp {
{
return app.clone();
}
let app = proc::executable(pid).map(|exe| describe(pid, &exe));
let app = proc::executable(pid).map(|exe| {
let mut app = describe(pid, &exe);
// A program's folder names it; an app's is wherever launchd started it.
if app.bundle_id.is_none() {
app.cwd = proc::cwd(pid);
}
app
});
if let Some(key) = key {
self.apps.insert(key, app.clone());
}
@@ -392,7 +401,13 @@ mod imp {
match outermost_bundle(exe) {
Some(bundle) => {
let (name, bundle_id) = bundle_info(bundle);
ClientApp { pid, name, bundle_id, path: bundle.to_string_lossy().into_owned() }
ClientApp {
pid,
name,
bundle_id,
path: bundle.to_string_lossy().into_owned(),
cwd: None,
}
}
None => ClientApp {
pid,
@@ -402,6 +417,7 @@ mod imp {
.unwrap_or_else(|| exe.to_string_lossy().into_owned()),
bundle_id: None,
path: exe.to_string_lossy().into_owned(),
cwd: None,
},
}
}
@@ -698,6 +714,35 @@ mod proc {
Some((info.pbi_start_tvsec, info.pbi_start_tvusec))
}
/// The working directory of `pid`, which only its own user or root can read.
pub fn cwd(pid: i32) -> Option<String> {
let mut info = std::mem::MaybeUninit::<libc::proc_vnodepathinfo>::zeroed();
let size = size_of::<libc::proc_vnodepathinfo>();
let written = unsafe {
libc::proc_pidinfo(
pid,
libc::PROC_PIDVNODEPATHINFO,
0,
info.as_mut_ptr().cast::<c_void>(),
size as c_int,
)
};
if written as usize != size {
return None;
}
let info = unsafe { info.assume_init() };
// A NUL-terminated C string, in libc's nested array.
let bytes: Vec<u8> = info
.pvi_cdir
.vip_path
.iter()
.flatten()
.map(|&c| c as u8)
.take_while(|&b| b != 0)
.collect();
(!bytes.is_empty()).then(|| String::from_utf8_lossy(&bytes).into_owned())
}
/// The path of the executable `pid` runs.
pub fn executable(pid: i32) -> Option<PathBuf> {
let mut buf = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
@@ -876,6 +921,7 @@ mod tests {
name: "Fake Browser".into(),
bundle_id: Some("com.example.browser".into()),
path: bundle.to_string_lossy().into_owned(),
cwd: None,
}
);
}
@@ -890,7 +936,8 @@ mod tests {
pid: 7,
name: "curl".into(),
bundle_id: None,
path: "/usr/bin/curl".into()
path: "/usr/bin/curl".into(),
cwd: None,
}
);
}
+120 -8
View File
@@ -77,14 +77,26 @@ pub fn open_blobs_read_only(path: &Path) -> Result<BlobManager> {
Ok(BlobManager::new(readers.clone(), readers))
}
/// `sidebar_path(url)`, the app sidebar's place for a URL, for filtering by its nodes.
/// The functions filters use: `sidebar_path(url)`, the app sidebar's place for a URL, and
/// `under_site(url, site)`, for filtering by its nodes; `folder_name(dir)`, for naming a program
/// by its folder; and `is_system(bundle_id, path)`, for macOS's own daemons.
fn add_functions(conn: &mut rusqlite::Connection) -> rusqlite::Result<()> {
conn.create_scalar_function(
"sidebar_path",
1,
FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC,
|ctx| Ok(crate::flows::sidebar_path(&ctx.get::<String>(0)?)),
)
let flags = FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC;
conn.create_scalar_function("sidebar_path", 1, flags, |ctx| {
Ok(crate::flows::sidebar_path(&ctx.get::<String>(0)?))
})?;
conn.create_scalar_function("under_site", 2, flags, |ctx| {
Ok(crate::flows::under_site(&ctx.get::<String>(0)?, &ctx.get::<String>(1)?))
})?;
conn.create_scalar_function("folder_name", 1, flags, |ctx| {
let dir = ctx.get::<Option<String>>(0)?;
Ok(crate::flows::folder_name(dir.as_deref()).map(str::to_string))
})?;
conn.create_scalar_function("is_system", 2, flags, |ctx| {
let bundle_id = ctx.get::<Option<String>>(0)?;
let path = ctx.get::<Option<String>>(1)?;
Ok(crate::flows::is_system(bundle_id.as_deref(), path.as_deref()))
})
}
fn read_only_pool(path: &Path) -> Result<SqlitePool> {
@@ -163,6 +175,26 @@ mod tests {
let failed =
crate::flows::FlowFilter { status: Some("error".into()), ..Default::default() };
assert_eq!(crate::flows::list(&db, &failed).unwrap().len(), 0);
// Filters by app work without the folder column, and count without the clear mark.
drop(db);
let conn = Connection::open(&path).unwrap();
conn.execute_batch(
"ALTER TABLE flows ADD COLUMN client_name TEXT;
ALTER TABLE flows ADD COLUMN client_bundle_id TEXT;
ALTER TABLE flows ADD COLUMN client_path TEXT;
ALTER TABLE flows DROP COLUMN client_cwd;
DROP TABLE flow_clear_mark;
UPDATE flows SET client_name = 'curl', client_path = '/usr/bin/curl';",
)
.unwrap();
drop(conn);
let db = ProxyQueryManager::open_read_only(&path).unwrap();
let filter =
crate::flows::FlowFilter { app: Some(vec!["curl".into()]), ..Default::default() };
let (page, total, matching) = crate::flows::page(&db, &filter).unwrap();
assert_eq!((page.len(), total, matching), (1, 1, Some(1)));
assert_eq!(page[0].number, 1);
}
#[test]
@@ -198,7 +230,16 @@ mod tests {
.unwrap()
.map(|t| t.unwrap())
.collect();
assert_eq!(tables, vec!["_sqlx_migrations", "flow_messages", "flows", "grpc_schemas"]);
assert_eq!(
tables,
vec![
"_sqlx_migrations",
"flow_clear_mark",
"flow_messages",
"flows",
"grpc_schemas"
]
);
let flow_columns = columns(&conn, "flows");
assert!(flow_columns.contains(&"response_truncated".to_string()));
@@ -207,6 +248,7 @@ mod tests {
"client_name",
"client_bundle_id",
"client_path",
"client_cwd",
"cancelled",
"stream_kind",
"message_count",
@@ -230,4 +272,74 @@ mod tests {
let journal_mode: String = conn.query_row("PRAGMA journal_mode", [], |r| r.get(0)).unwrap();
assert_eq!(journal_mode, "wal");
}
#[test]
fn takes_the_folder_and_clear_mark_after_the_messages_migration() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("proxy.db");
// As a build with every migration up to the messages one left it, with a stream in it.
let conn = Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE _sqlx_migrations (
version TEXT PRIMARY KEY,
description TEXT NOT NULL,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
);",
)
.unwrap();
let mut files: Vec<_> = MIGRATIONS
.files()
.map(|f| (f.path().to_string_lossy().into_owned(), f.contents_utf8().unwrap()))
.filter(|(name, _)| name.as_str() < "00000007")
.collect();
files.sort();
assert_eq!(files.len(), 6);
for (name, sql) in files {
conn.execute_batch(sql).unwrap();
conn.execute(
"INSERT INTO _sqlx_migrations (version, description) VALUES (?, ?)",
[&name[..8], &name],
)
.unwrap();
}
conn.execute_batch(
"INSERT INTO flows (id, url, method, stream_kind, message_count, client_path)
VALUES ('fl_ws', 'wss://example.com/socket', 'GET', 'websocket', 2, '/bin/node');
INSERT INTO flow_messages (id, flow_id, seq, direction, kind, timestamp)
VALUES ('fm_1', 'fl_ws', 1, 'client', 'text', CURRENT_TIMESTAMP);",
)
.unwrap();
drop(conn);
drop(ProxyQueryManager::new(&path).unwrap());
let conn = Connection::open(&path).unwrap();
assert!(columns(&conn, "flows").contains(&"client_cwd".to_string()));
let applied: Vec<String> = conn
.prepare("SELECT version FROM _sqlx_migrations ORDER BY version")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.map(|v| v.unwrap())
.collect();
assert_eq!(&applied[applied.len() - 2..], ["00000007", "00000008"]);
let mark_columns = columns(&conn, "flow_clear_mark");
assert_eq!(mark_columns, ["id", "max_rowid", "cleaner", "cleaner_at"]);
drop(conn);
// The stream and its messages come through, and clearing then deleting takes both.
let db = ProxyQueryManager::new(&path).unwrap();
let blobs = yaak_models::init_blob_store(dir.path().join("blobs.sqlite")).unwrap();
let listed = crate::flows::list(&db, &Default::default()).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!((listed[0].id.as_str(), listed[0].message_count), ("fl_ws", 2));
assert_eq!(crate::messages::count(&db, "fl_ws").unwrap(), 1);
assert_eq!(crate::flows::clear(&db).unwrap(), 1);
assert_eq!(
crate::flows::delete_cleared(&db, &blobs, 500, "cln_test"),
Ok(crate::flows::Cleaned::Done)
);
assert_eq!(crate::messages::count(&db, "fl_ws").unwrap(), 0);
}
}
+583 -54
View File
@@ -1,6 +1,6 @@
use crate::bodies;
use crate::db::ProxyQueryManager;
use crate::models::{Flow, FlowBody, FlowIden};
use crate::models::{Flow, FlowBody};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use rusqlite::types::Value;
@@ -22,13 +22,25 @@ pub struct FlowFilter {
#[ts(optional)]
pub host: Option<String>,
/// Only flows under one of these hosts or paths, as the app's sidebar names them:
/// `api.stripe.com` or `api.stripe.com/v1/charges`, with any port after the host.
/// `api.stripe.com` or `api.stripe.com/v1/charges`, with any port after the host. A host
/// written `*.github.com` is a site: `github.com` and every host below it.
#[ts(optional)]
pub under: Option<Vec<String>>,
/// Only flows from one of these apps, by bundle id, or by path for a process outside an
/// app bundle.
/// Only flows from one of these apps: by bundle id, by path for a process outside an app
/// bundle, by name in any case (`slack`), or, for a process outside an app, by the folder
/// it ran in (`fieldwork`).
#[ts(optional)]
pub app: Option<Vec<String>>,
/// Only flows from macOS's own daemons (true), or none of them (false), as [`is_system`]
/// tells them by their executable.
#[ts(optional)]
pub system: Option<bool>,
/// Only flows one of these groups covers, each judged on its own: several sidebar rows
/// selected, `@app:Slack @host:a.com OR @app:Chrome @host:b.com`, are two groups and not every
/// pairing of their apps and hosts. A group's `after`, `before` and `limit` do not apply. Not
/// carried by a query string.
#[ts(optional)]
pub any: Option<Vec<FlowFilter>>,
/// The method, any case.
#[ts(optional)]
pub method: Option<String>,
@@ -50,8 +62,8 @@ pub struct FlowFilter {
}
impl FlowFilter {
/// The filter from a query string's `q`, `host`, `under`, `app`, `method`, `status`,
/// `limit`, `after` and `before`. `under` and `app` may repeat.
/// The filter from a query string's `q`, `host`, `under`, `app`, `system`, `method`,
/// `status`, `limit`, `after` and `before`. `under` and `app` may repeat.
pub fn from_query(query: &str) -> Result<Self, String> {
let mut filter = Self::default();
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
@@ -61,6 +73,11 @@ impl FlowFilter {
"host" => filter.host = value,
"under" => filter.under.get_or_insert_default().extend(value),
"app" => filter.app.get_or_insert_default().extend(value),
"system" => {
filter.system = value
.map(|v| v.parse().map_err(|_| format!("Invalid system {v:?}")))
.transpose()?
}
"method" => filter.method = value,
"status" => filter.status = value,
"after" => filter.after = value,
@@ -79,7 +96,8 @@ impl FlowFilter {
pub fn to_query(&self) -> String {
let mut query = url::form_urlencoded::Serializer::new(String::new());
let limit = self.limit.map(|l| l.to_string());
for (key, value) in [("q", &self.q), ("host", &self.host)] {
let system = self.system.map(|s| s.to_string());
for (key, value) in [("q", &self.q), ("host", &self.host), ("system", &system)] {
if let Some(value) = value {
query.append_pair(key, value);
}
@@ -112,12 +130,20 @@ impl FlowFilter {
|| given(&self.status)
|| self.under.is_some()
|| self.app.is_some()
|| self.system.is_some()
|| self.any.as_ref().is_some_and(|groups| groups.iter().any(FlowFilter::narrows))
}
/// The SQL condition for every field but `after`, `before` and `limit`, and its parameters.
/// `grpc` says the database has gRPC statuses: one opened read-only before its migrations
/// have run may not.
fn condition(&self, grpc: bool) -> (String, Vec<Value>) {
/// The SQL condition for every field but `after`, `before` and `limit`, and its parameters,
/// for the database `conn` reads, which may be from before the folder or gRPC columns, when
/// read without migrating.
fn condition_in(&self, conn: &rusqlite::Connection) -> (String, Vec<Value>) {
self.condition(has_column(conn, "client_cwd"), has_column(conn, "grpc_status"))
}
/// [`Self::condition_in`], told which of the later columns the database has: `has_cwd`,
/// the folder a program ran in, and `grpc`, gRPC statuses.
fn condition(&self, has_cwd: bool, grpc: bool) -> (String, Vec<Value>) {
let mut sql = String::from("1");
let mut params: Vec<Value> = Vec::new();
for term in self.q.iter().flat_map(|q| q.split_whitespace()) {
@@ -145,6 +171,11 @@ impl FlowFilter {
// The node itself, or anything below it, by the sidebar's rules.
let mut any = Vec::new();
for node in under {
if let Some(site) = site_of_node(node) {
any.push("under_site(url, ?)");
params.push(site.into());
continue;
}
let node = normalize_node(node);
any.push("(sidebar_path(url) = ? OR substr(sidebar_path(url), 1, ?) = ?)");
let below = format!("{node}/");
@@ -159,8 +190,34 @@ impl FlowFilter {
}
if let Some(apps) = &self.app {
let marks = vec!["?"; apps.len()].join(", ");
sql.push_str(&format!(" AND coalesce(client_bundle_id, client_path) IN ({marks})"));
sql.push_str(&format!(
" AND (coalesce(client_bundle_id, client_path) IN ({marks}) \
OR lower(client_name) IN ({marks})"
));
params.extend(apps.iter().map(|a| a.clone().into()));
params.extend(apps.iter().map(|a| a.to_lowercase().into()));
if has_cwd {
sql.push_str(&format!(
" OR (client_bundle_id IS NULL AND lower(folder_name(client_cwd)) IN ({marks}))"
));
params.extend(apps.iter().map(|a| a.to_lowercase().into()));
}
sql.push(')');
}
match self.system {
Some(true) => sql.push_str(" AND is_system(client_bundle_id, client_path)"),
Some(false) => sql.push_str(" AND NOT is_system(client_bundle_id, client_path)"),
None => {}
}
// Each group whole, then any of them.
if let Some(groups) = self.any.as_ref().filter(|g| !g.is_empty()) {
let mut any = Vec::new();
for group in groups {
let (condition, group_params) = group.condition(has_cwd, grpc);
any.push(format!("({condition})"));
params.extend(group_params);
}
sql.push_str(&format!(" AND ({})", any.join(" OR ")));
}
if let Some(method) = self.method.as_deref().filter(|m| !m.is_empty()) {
sql.push_str(" AND lower(method) = ?");
@@ -231,6 +288,75 @@ fn normalize_node(node: &str) -> String {
}
}
/// The site a sidebar node names, `github.com` for `*.github.com`, lowercased. None for a host or
/// a path.
fn site_of_node(node: &str) -> Option<String> {
let site = node.strip_prefix("*.")?;
(!site.is_empty() && !site.contains('/')).then(|| site.to_ascii_lowercase())
}
/// `url`'s host, without its port, is `site` or a host below it: `api.github.com` is under
/// `github.com`.
pub fn under_site(url: &str, site: &str) -> bool {
let path = sidebar_path(url);
let host = path.split('/').next().unwrap_or_default();
let host = strip_port(host);
host == site || host.strip_suffix(site).is_some_and(|rest| rest.ends_with('.'))
}
/// `host` without a `:port` after it, keeping an IPv6 address's brackets.
fn strip_port(host: &str) -> &str {
if host.starts_with('[') {
return host.find(']').map_or(host, |end| &host[..=end]);
}
host.rsplit_once(':').map_or(host, |(h, _)| h)
}
/// The last part of a directory's path, `fieldwork` for `/Users/me/src/fieldwork`. None for the
/// root, where daemons run, or no path.
pub fn folder_name(path: Option<&str>) -> Option<&str> {
let name = path?.trim_end_matches('/').rsplit('/').next()?;
(!name.is_empty()).then_some(name)
}
/// Where macOS keeps its own daemons and tools.
const SYSTEM_DIRS: &[&str] = &[
"/System/",
"/usr/libexec/",
"/usr/sbin/",
"/sbin/",
"/Library/Apple/",
];
/// Daemons that are macOS's own wherever they run from.
const SYSTEM_PROGRAMS: &[&str] = &[
"akd",
"apsd",
"cloudd",
"mDNSResponder",
"nsurlsessiond",
"ocspd",
"softwareupdated",
"trustd",
];
/// A flow came from one of macOS's own daemons rather than an app or a program someone ran: a
/// process outside an app bundle whose executable is under `/System`, `/usr/libexec` and the
/// like, or one of a few daemons by name. WebKit's helper processes, which also live under
/// `/System`, send apps' traffic and are not. `isSystemFlow` in the app's `lib/search.ts`
/// follows the same rules.
pub fn is_system(bundle_id: Option<&str>, path: Option<&str>) -> bool {
let Some(path) = path else { return false };
if bundle_id.is_some() {
return false;
}
let name = path.rsplit('/').next().unwrap_or_default();
if name.starts_with("com.apple.WebKit.") {
return false;
}
SYSTEM_DIRS.iter().any(|dir| path.starts_with(dir)) || SYSTEM_PROGRAMS.contains(&name)
}
/// The SQL condition for a status term: `404` exactly, `40` as the start of one, or a class
/// such as `4xx`. None when the term is not one.
fn status_condition(term: &str, params: &mut Vec<Value>) -> Option<String> {
@@ -276,13 +402,17 @@ pub fn page(
}
/// The database has the gRPC status column, added by a later migration.
fn has_grpc_status(conn: &rusqlite::Connection) -> bool {
conn.prepare("SELECT grpc_status FROM flows LIMIT 0").is_ok()
/// The flows table has `column`: a database opened read-only before its migrations have run may
/// not.
fn has_column(conn: &rusqlite::Connection, column: &str) -> bool {
conn.prepare(&format!("SELECT {column} FROM flows LIMIT 0")).is_ok()
}
fn list_in(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result<Vec<Flow>, String> {
let (condition, mut params) = filter.condition(has_grpc_status(conn));
let mut sql = format!("SELECT * FROM flows WHERE {condition}");
let (condition, mut params) = filter.condition_in(conn);
// The rowid, for the flow's number.
let mut sql = format!("SELECT rowid, * FROM flows WHERE {condition}");
push_clear_mark(conn, &mut sql, &mut params);
if let Some(id) = &filter.after {
// By rowid, which only grows as flows are written.
let rowid: i64 = conn
@@ -312,44 +442,205 @@ fn list_in(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result<Vec<Flow>
}
fn count_matching(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result<usize, String> {
let (condition, params) = filter.condition(has_grpc_status(conn));
conn.query_row(
&format!("SELECT COUNT(*) FROM flows WHERE {condition}"),
rusqlite::params_from_iter(params),
|r| r.get::<_, i64>(0),
)
.map(|n| n as usize)
.map_err(|e| e.to_string())
let (condition, mut params) = filter.condition_in(conn);
let mut sql = format!("SELECT COUNT(*) FROM flows WHERE {condition}");
push_clear_mark(conn, &mut sql, &mut params);
conn.query_row(&sql, rusqlite::params_from_iter(params), |r| r.get::<_, i64>(0))
.map(|n| n as usize)
.map_err(|e| e.to_string())
}
/// Deletes every flow and their bodies, in a transaction in each database. The flows' deletion
/// commits only after their bodies' has, so when clearing the bodies fails the flows stay, to be
/// cleared again. Returns how many there were.
pub fn clear(db: &ProxyQueryManager, blobs: &BlobManager) -> Result<usize, String> {
/// Leaves out cleared flows, when a clear is pending; otherwise adds nothing, so SQLite keeps its
/// cheapest plan.
fn push_clear_mark(conn: &rusqlite::Connection, sql: &mut String, params: &mut Vec<Value>) {
let mark = clear_mark(conn);
if mark > 0 {
sql.push_str(" AND rowid > ?");
params.push(mark.into());
}
}
/// The rowid at or below which flows are cleared, or 0 when none are waiting to be deleted. 0
/// too for a database from before the mark, read without migrating.
pub fn clear_mark(conn: &rusqlite::Connection) -> i64 {
conn.query_row("SELECT max_rowid FROM flow_clear_mark WHERE id = 1", [], |r| r.get(0))
.unwrap_or(0)
}
/// Clears every flow at once: marks the newest flow's rowid, so no listing or count sees it or
/// any before it, and leaves the deleting to [`delete_cleared`]. Flows written afterwards are
/// unaffected. Returns how many flows were cleared.
pub fn clear(db: &ProxyQueryManager) -> Result<usize, String> {
db.with_tx(|c| {
let conn = c.conn().resolve();
let mut stmt = conn.prepare("SELECT id FROM flows")?;
let ids = stmt.query_map([], |r| r.get(0))?.collect::<Result<Vec<String>, _>>()?;
conn.execute("DELETE FROM flows", [])?;
conn.execute("DELETE FROM flow_messages", [])?;
// Only the deleted flows' bodies, so a flow still in flight keeps what it has written.
let mark = clear_mark(conn);
let (newest, cleared): (Option<i64>, i64) = conn.query_row(
"SELECT max(rowid), count(*) FROM flows WHERE rowid > ?",
[mark],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
if let Some(newest) = newest {
conn.execute(
"INSERT INTO flow_clear_mark (id, max_rowid) VALUES (1, ?) \
ON CONFLICT (id) DO UPDATE SET max_rowid = excluded.max_rowid",
[newest],
)?;
}
Ok::<_, yaak_database::Error>(cleared as usize)
})
.map_err(|e| format!("Failed to clear flows: {e}"))
}
/// Flows deleted per transaction while deleting cleared ones, so a flow being written never
/// waits long behind it.
pub const DELETE_BATCH: usize = 500;
/// How long a cleaner may go without a batch before another process takes the deleting over.
const CLEANER_LEASE_MS: i64 = 30_000;
/// What a batch of deleting cleared flows left.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Cleaned {
/// More cleared flows wait.
More,
/// None are left, and the mark is gone.
Done,
/// Another process holds the lease and is deleting them.
Elsewhere,
}
/// Deletes up to `batch` cleared flows, their messages and their bodies, as `cleaner`, the
/// process's name for itself. Once none are left, removes the mark in the same transaction as
/// the last of them, so a clear that raised it meanwhile is never lost.
///
/// One process deletes at a time, the one holding the mark's lease, which each batch renews:
/// the app and the CLI share the database, and two cleaners could otherwise race. The bodies go
/// first, in their own transaction, and the rows after, so when deleting the bodies fails the
/// flows stay, to be deleted again. The two writers are never held together: a flow being
/// written holds the bodies' while it waits for the flows', and the other order would stall them
/// both.
pub fn delete_cleared(
db: &ProxyQueryManager,
blobs: &BlobManager,
batch: usize,
cleaner: &str,
) -> Result<Cleaned, String> {
let fail = |e: String| format!("Failed to delete cleared flows: {e}");
if clear_mark_in(db) == 0 {
return Ok(Cleaned::Done);
}
if !take_lease(db, cleaner).map_err(|e| fail(e.to_string()))? {
return Ok(Cleaned::Elsewhere);
}
let rows = read_cleared(db, batch).map_err(|e| fail(e.to_string()))?;
// Their bodies and their messages' payloads, by flow, so a flow still in flight keeps what
// it has written.
if !rows.is_empty() {
blobs
.with_tx(|b| {
for id in &ids {
for (_, id) in &rows {
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| yaak_database::Error::Database(format!("flow bodies: {e}")))?;
Ok::<_, yaak_database::Error>(ids.len())
})
.map_err(|e| format!("Failed to clear flows: {e}"))
.map_err(|e| fail(format!("flow bodies: {e}")))?;
}
delete_rows(db, &rows, cleaner).map_err(|e| fail(e.to_string()))
}
fn now_ms() -> i64 {
chrono::Utc::now().timestamp_millis()
}
/// Takes or renews the lease on deleting cleared flows for `cleaner`. False while another
/// process holds it, or when nothing is cleared.
fn take_lease(db: &ProxyQueryManager, cleaner: &str) -> Result<bool, yaak_database::Error> {
db.with_tx(|c| {
let now = now_ms();
let taken = c.conn().resolve().execute(
"UPDATE flow_clear_mark SET cleaner = ?1, cleaner_at = ?2 WHERE id = 1 \
AND (cleaner IS NULL OR cleaner = ?1 OR cleaner_at IS NULL OR cleaner_at < ?3)",
rusqlite::params![cleaner, now, now - CLEANER_LEASE_MS],
)?;
Ok(taken == 1)
})
}
/// The next `batch` cleared flows, by rowid and id.
fn read_cleared(db: &ProxyQueryManager, batch: usize) -> rusqlite::Result<Vec<(i64, String)>> {
db.with_conn(|c| {
let conn = c.conn().resolve();
let mut stmt =
conn.prepare("SELECT rowid, id FROM flows WHERE rowid <= ? ORDER BY rowid LIMIT ?")?;
stmt.query_map([clear_mark(conn), batch as i64], |r| Ok((r.get(0)?, r.get(1)?)))?.collect()
})
}
/// Deletes `rows`, read a moment ago, and their messages, while `cleaner` still holds the lease
/// and only those still under the mark with the same id: read since, the mark may be gone and a
/// new flow have the rowid again. Drops the mark once nothing is left under it.
fn delete_rows(
db: &ProxyQueryManager,
rows: &[(i64, String)],
cleaner: &str,
) -> Result<Cleaned, yaak_database::Error> {
db.with_tx(|c| {
let conn = c.conn().resolve();
let held: Option<Option<String>> = conn
.query_row("SELECT cleaner FROM flow_clear_mark WHERE id = 1", [], |r| r.get(0))
.ok();
match held {
// The mark went meanwhile: a rowid read may be a new flow's now.
None => return Ok(Cleaned::Done),
Some(held) if held.as_deref() != Some(cleaner) => return Ok(Cleaned::Elsewhere),
Some(_) => {}
}
let mark = clear_mark(conn);
let mut delete =
conn.prepare("DELETE FROM flows WHERE rowid = ? AND id = ? AND rowid <= ?")?;
let mut delete_messages = conn.prepare("DELETE FROM flow_messages WHERE flow_id = ?")?;
for (rowid, id) in rows {
if delete.execute(rusqlite::params![rowid, id, mark])? > 0 {
delete_messages.execute([id])?;
}
}
// With none left under it, the mark goes in this transaction, since an empty table
// hands out rowids from 1 again and a new flow would fall under it.
let left: bool =
conn.query_row("SELECT EXISTS (SELECT 1 FROM flows WHERE rowid <= ?)", [mark], |r| {
r.get(0)
})?;
if left {
conn.execute("UPDATE flow_clear_mark SET cleaner_at = ? WHERE id = 1", [now_ms()])?;
Ok(Cleaned::More)
} else {
conn.execute("DELETE FROM flow_clear_mark WHERE id = 1", [])?;
Ok(Cleaned::Done)
}
})
}
/// [`clear_mark`], read from `db`.
pub fn clear_mark_in(db: &ProxyQueryManager) -> i64 {
db.with_conn(|c| clear_mark(c.conn().resolve()))
}
/// A flow by id, with its number. None once it is cleared, though its row may not be deleted
/// yet.
pub fn find(db: &ProxyQueryManager, flow_id: &str) -> Option<Flow> {
db.with_conn(|c| c.find_optional::<Flow>(FlowIden::Id, flow_id))
db.with_conn(|c| find_in(c.conn().resolve(), flow_id))
}
/// [`find`], inside a transaction or on a connection already held.
pub fn find_in(conn: &rusqlite::Connection, flow_id: &str) -> Option<Flow> {
conn.query_row(
"SELECT rowid, * FROM flows WHERE id = ? AND rowid > ?",
rusqlite::params![flow_id, clear_mark(conn)],
Flow::from_row,
)
.ok()
}
pub fn get(db: &ProxyQueryManager, flow_id: &str) -> Result<Flow, String> {
@@ -361,9 +652,14 @@ pub fn count(db: &ProxyQueryManager) -> Result<usize, String> {
}
fn count_in(conn: &rusqlite::Connection) -> Result<usize, String> {
conn.query_row("SELECT COUNT(*) FROM flows", [], |r| r.get::<_, i64>(0))
.map(|n| n as usize)
.map_err(|e| e.to_string())
// Without a clear pending, SQLite counts over its smallest index rather than the table.
let mark = clear_mark(conn);
let count = if mark == 0 {
conn.query_row("SELECT COUNT(*) FROM flows", [], |r| r.get::<_, i64>(0))
} else {
conn.query_row("SELECT COUNT(*) FROM flows WHERE rowid > ?", [mark], |r| r.get(0))
};
count.map(|n| n as usize).map_err(|e| e.to_string())
}
/// A body in JSON: text when it is UTF-8, base64 when it is not.
@@ -648,7 +944,7 @@ mod tests {
let plan: Vec<String> = db.with_conn(|c| {
let conn = c.conn().resolve();
let (condition, params) =
FlowFilter { q: Some("stripe".into()), ..Default::default() }.condition(true);
FlowFilter { q: Some("stripe".into()), ..Default::default() }.condition_in(conn);
let sql = format!(
"EXPLAIN QUERY PLAN SELECT * FROM flows WHERE {condition} \
ORDER BY started_at DESC, id DESC LIMIT 500"
@@ -732,7 +1028,7 @@ mod tests {
}
#[test]
fn clear_deletes_flows_and_their_bodies() {
fn clear_marks_flows_then_deletes_them_in_batches() {
use yaak_database::UpdateSource;
use yaak_models::blob_manager::BodyChunkWriter;
let dir = tempfile::tempdir().unwrap();
@@ -743,25 +1039,258 @@ mod tests {
writer.write(b"body").unwrap();
writer.finish().unwrap();
};
for id in ["fl_a", "fl_b"] {
let write_flow = |id: &str| {
write_body(id, FlowBody::Request);
write_body(id, FlowBody::Response);
let flow = Flow { id: id.into(), ..Default::default() };
db.with_tx(|tx| tx.upsert(&flow, &UpdateSource::Background)).unwrap();
};
let rows = || {
db.with_conn(|c| {
c.conn()
.resolve()
.query_row("SELECT count(*) FROM flows", [], |r| r.get::<_, i64>(0))
.unwrap()
})
};
let ids = |filter: &FlowFilter| -> Vec<String> {
list(&db, filter).unwrap().into_iter().map(|f| f.id).collect()
};
for id in ["fl_a", "fl_b", "fl_c"] {
write_flow(id);
}
// A flow still in flight has written a body but not its row.
write_body("fl_live", FlowBody::Request);
// Bodies that fail to clear keep their flows, to clear again.
let read_only = crate::db::open_blobs_read_only(&dir.path().join("blobs.sqlite")).unwrap();
assert!(clear(&db, &read_only).is_err());
assert_eq!(count(&db).unwrap(), 2);
assert_eq!(clear(&db, &blobs).unwrap(), 2);
// Cleared at once: no listing, count or lookup sees them, though their rows remain.
assert_eq!(clear(&db).unwrap(), 3);
assert_eq!(count(&db).unwrap(), 0);
let body_ids = blobs.connect().list_body_ids().unwrap();
assert_eq!(body_ids, vec![Flow::body_id("fl_live", FlowBody::Request)]);
assert_eq!(clear(&db, &blobs).unwrap(), 0);
assert_eq!(ids(&FlowFilter::default()), Vec::<String>::new());
assert!(find(&db, "fl_a").is_none());
assert_eq!(rows(), 3);
assert_eq!(db.with_conn(|c| clear_mark(c.conn().resolve())), 3);
// A flow written after the clear is unaffected, and a second clear takes only it.
write_flow("fl_d");
assert_eq!(ids(&FlowFilter::default()), vec!["fl_d"]);
assert_eq!(find(&db, "fl_d").map(|f| f.number), Some(4));
let narrowed = FlowFilter { q: Some("example".into()), ..Default::default() };
assert_eq!(page(&db, &narrowed).unwrap().1, 1);
// Bodies that fail to delete keep their flows, to delete again.
let read_only = crate::db::open_blobs_read_only(&dir.path().join("blobs.sqlite")).unwrap();
assert!(delete_cleared(&db, &read_only, 2, "cln_a").is_err());
assert_eq!(rows(), 4);
// In batches, the mark going with the last of them.
assert_eq!(delete_cleared(&db, &blobs, 2, "cln_a"), Ok(Cleaned::More));
assert_eq!(rows(), 2);
assert_eq!(delete_cleared(&db, &blobs, 2, "cln_a"), Ok(Cleaned::Done));
assert_eq!(rows(), 1);
assert_eq!(db.with_conn(|c| clear_mark(c.conn().resolve())), 0);
assert_eq!(ids(&FlowFilter::default()), vec!["fl_d"]);
let mut body_ids = blobs.connect().list_body_ids().unwrap();
body_ids.sort();
assert_eq!(
body_ids,
vec![
Flow::body_id("fl_d", FlowBody::Request),
Flow::body_id("fl_d", FlowBody::Response),
Flow::body_id("fl_live", FlowBody::Request),
]
);
assert_eq!(clear(&db).unwrap(), 1);
assert_eq!(clear(&db).unwrap(), 0);
assert_eq!(delete_cleared(&db, &blobs, DELETE_BATCH, "cln_a"), Ok(Cleaned::Done));
assert_eq!(rows(), 0);
// An empty table numbers from 1 again, which no stale mark may hide.
write_flow("fl_e");
assert_eq!(ids(&FlowFilter::default()), vec!["fl_e"]);
assert_eq!(delete_cleared(&db, &blobs, DELETE_BATCH, "cln_a"), Ok(Cleaned::Done));
assert_eq!(rows(), 1);
}
#[test]
fn one_cleaner_at_a_time_and_never_a_reused_rowid() {
use yaak_database::UpdateSource;
let dir = tempfile::tempdir().unwrap();
let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap();
let blobs = yaak_models::init_blob_store(dir.path().join("blobs.sqlite")).unwrap();
let write_flow = |id: &str| {
let flow = Flow { id: id.into(), ..Default::default() };
db.with_tx(|tx| tx.upsert(&flow, &UpdateSource::Background)).unwrap().0
};
let rows = || -> Vec<String> {
db.with_conn(|c| {
let conn = c.conn().resolve();
let mut stmt = conn.prepare("SELECT id FROM flows ORDER BY rowid").unwrap();
stmt.query_map([], |r| r.get(0)).unwrap().map(|r| r.unwrap()).collect()
})
};
for id in ["fl_a", "fl_b", "fl_c"] {
write_flow(id);
}
assert_eq!(clear(&db).unwrap(), 3);
// The app takes the lease; the CLI, sharing the database, leaves the deleting to it.
assert_eq!(delete_cleared(&db, &blobs, 1, "cln_app"), Ok(Cleaned::More));
assert_eq!(delete_cleared(&db, &blobs, 1, "cln_cli"), Ok(Cleaned::Elsewhere));
assert_eq!(rows(), vec!["fl_b", "fl_c"]);
// A lease gone stale passes to the next cleaner.
let exec = |sql: &str| {
db.with_tx(|c| Ok::<_, yaak_database::Error>(c.conn().resolve().execute(sql, [])?))
.unwrap()
};
exec("UPDATE flow_clear_mark SET cleaner_at = 0");
assert_eq!(delete_cleared(&db, &blobs, 1, "cln_cli"), Ok(Cleaned::More));
assert_eq!(delete_cleared(&db, &blobs, 1, "cln_app"), Ok(Cleaned::Elsewhere));
// A batch read before the last one finished: by then the mark is gone, the table empty,
// and a new flow has the same rowid as the one read. It is not deleted.
let stale = read_cleared(&db, DELETE_BATCH).unwrap();
assert_eq!(stale.iter().map(|(_, id)| id.as_str()).collect::<Vec<_>>(), vec!["fl_c"]);
assert_eq!(delete_cleared(&db, &blobs, DELETE_BATCH, "cln_cli"), Ok(Cleaned::Done));
exec(&format!("INSERT INTO flows (rowid, id) VALUES ({}, 'fl_new')", stale[0].0));
assert_eq!(delete_rows(&db, &stale, "cln_cli").unwrap(), Cleaned::Done);
assert_eq!(rows(), vec!["fl_new"]);
// Under a new mark, the same rowid with another id is not the flow read either.
assert_eq!(clear(&db).unwrap(), 1);
exec("UPDATE flow_clear_mark SET cleaner = 'cln_cli'");
assert_eq!(delete_rows(&db, &stale, "cln_cli").unwrap(), Cleaned::More);
assert_eq!(rows(), vec!["fl_new"]);
}
#[test]
fn numbers_flows_by_rowid_and_keeps_it_on_update() {
use yaak_database::UpdateSource;
let dir = tempfile::tempdir().unwrap();
let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap();
let write = |flow: &Flow| db.with_tx(|tx| tx.upsert(flow, &UpdateSource::Background));
let (a, _) = write(&Flow { id: "fl_a".into(), ..Default::default() }).unwrap();
let (b, _) = write(&Flow { id: "fl_b".into(), ..Default::default() }).unwrap();
assert_eq!((a.number, b.number), (1, 2));
let (a, created) = write(&Flow { status: Some(200), ..a }).unwrap();
assert_eq!((a.number, created), (1, false));
let numbers: Vec<i64> =
list(&db, &FlowFilter::default()).unwrap().iter().map(|f| f.number).collect();
assert_eq!(numbers, vec![2, 1]);
assert_eq!(find(&db, "fl_b").map(|f| f.number), Some(2));
}
#[test]
fn filters_by_app_name_folder_site_and_system() {
use yaak_database::UpdateSource;
let dir = tempfile::tempdir().unwrap();
let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap();
let flows = [
Flow {
id: "fl_slack".into(),
url: "https://edgeapi.slack.com/cache".into(),
client_name: Some("Slack".into()),
client_bundle_id: Some("com.tinyspeck.slackmacgap".into()),
client_path: Some("/Applications/Slack.app".into()),
..Default::default()
},
Flow {
id: "fl_node".into(),
url: "https://api.github.com:443/repos".into(),
client_name: Some("node".into()),
client_path: Some("/opt/homebrew/bin/node".into()),
client_cwd: Some("/Users/me/src/fieldwork".into()),
..Default::default()
},
Flow {
id: "fl_daemon".into(),
url: "https://github.com/login".into(),
client_name: Some("nsurlsessiond".into()),
client_path: Some("/usr/libexec/nsurlsessiond".into()),
client_cwd: Some("/".into()),
..Default::default()
},
Flow {
id: "fl_none".into(),
url: "http://notgithub.com/".into(),
..Default::default()
},
];
for flow in &flows {
db.with_tx(|tx| tx.upsert(flow, &UpdateSource::Background)).unwrap();
}
let ids = |filter: FlowFilter| -> Vec<String> {
let mut ids: Vec<String> =
list(&db, &filter).unwrap().into_iter().map(|f| f.id).collect();
ids.sort();
ids
};
let app = |a: &str| FlowFilter { app: Some(vec![a.into()]), ..Default::default() };
assert_eq!(ids(app("slack")), vec!["fl_slack"]);
assert_eq!(ids(app("com.tinyspeck.slackmacgap")), vec!["fl_slack"]);
assert_eq!(ids(app("Fieldwork")), vec!["fl_node"]);
assert_eq!(ids(app("/opt/homebrew/bin/node")), vec!["fl_node"]);
let under = |n: &str| FlowFilter { under: Some(vec![n.into()]), ..Default::default() };
assert_eq!(ids(under("*.github.com")), vec!["fl_daemon", "fl_node"]);
assert_eq!(ids(under("api.github.com")), vec!["fl_node"]);
let system = |s| FlowFilter { system: Some(s), ..Default::default() };
assert_eq!(ids(system(true)), vec!["fl_daemon"]);
assert_eq!(ids(system(false)), vec!["fl_node", "fl_none", "fl_slack"]);
// Groups, as two sidebar rows under different apps write them, each judged whole:
// flattened, the apps and hosts would pair across rows and match both flows.
let group = |a: &str, n: &str| FlowFilter {
app: Some(vec![a.into()]),
under: Some(vec![n.into()]),
..Default::default()
};
let crossed = FlowFilter {
any: Some(vec![
group("fieldwork", "github.com"),
group("nsurlsessiond", "api.github.com"),
]),
..Default::default()
};
assert_eq!(ids(crossed.clone()), Vec::<String>::new());
assert_eq!(page(&db, &crossed).unwrap().2, Some(0));
let flattened = FlowFilter {
app: Some(vec!["fieldwork".into(), "nsurlsessiond".into()]),
under: Some(vec!["github.com".into(), "api.github.com".into()]),
..Default::default()
};
assert_eq!(ids(flattened), vec!["fl_daemon", "fl_node"]);
let either = FlowFilter {
any: Some(vec![
group("fieldwork", "api.github.com"),
FlowFilter { app: Some(vec!["slack".into()]), ..Default::default() },
]),
q: Some("https".into()),
..Default::default()
};
assert_eq!(ids(either), vec!["fl_node", "fl_slack"]);
}
#[test]
fn tells_system_daemons_apart() {
assert!(is_system(None, Some("/usr/libexec/nsurlsessiond")));
assert!(is_system(None, Some("/System/Library/PrivateFrameworks/X.framework/geod")));
assert!(is_system(None, Some("/somewhere/else/trustd")));
assert!(!is_system(None, Some("/usr/bin/curl")));
assert!(!is_system(None, Some("/opt/homebrew/bin/node")));
// An app in /System is an app, and WebKit's helpers send apps' traffic.
assert!(!is_system(Some("com.apple.Music"), Some("/System/Applications/Music.app")));
assert!(!is_system(
None,
Some(
"/System/Library/Frameworks/WebKit.framework/XPCServices/com.apple.WebKit.Networking.xpc/Contents/MacOS/com.apple.WebKit.Networking"
)
));
assert!(!is_system(None, None));
assert_eq!(folder_name(Some("/Users/me/src/fieldwork/")), Some("fieldwork"));
assert_eq!(folder_name(Some("/")), None);
assert!(under_site("https://api.github.com:8443/x", "github.com"));
assert!(!under_site("https://notgithub.com/", "github.com"));
}
#[test]
+136 -10
View File
@@ -27,7 +27,7 @@ use crate::grpc::{DecodedGrpcMessage, GrpcSchema, GrpcState};
use crate::helper::HelperState;
use crate::macos::{SystemProxyBackend, SystemProxyStatus};
use crate::messages::FlowMessage;
use crate::models::{Flow, FlowBody, FlowIden, ModelPayload, ProxyHeader, StreamKind, find_header};
use crate::models::{Flow, FlowBody, ModelPayload, ProxyHeader, StreamKind, find_header};
use crate::save::{ClientDb, SaveFlowRequest, SaveFlowResponse, WorkspaceSummary};
use crate::settings::{HttpVersion, ProxySettings};
use base64::Engine;
@@ -40,7 +40,7 @@ use std::io::Write;
use std::net::{SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use std::time::{Duration, SystemTime};
use ts_rs::TS;
use yaak_database::{ModelChangeEvent, UpdateSource, UpsertModelInfo};
@@ -72,6 +72,12 @@ pub struct ProxyCtx {
system_proxy_on: Arc<AtomicBool>,
/// Why the engine last failed to start, until it starts.
start_error: Arc<Mutex<Option<String>>>,
/// Cleared flows are being deleted in the background. Held while the deleter decides it is
/// done, so a clear either sees it running or starts another.
deleting_cleared: Arc<Mutex<bool>>,
/// This context's name as the one deleting cleared flows, which the database leases to one
/// process at a time.
cleaner_id: String,
pub db: ProxyQueryManager,
pub blobs: BlobManager,
pub events: RpcEventEmitter,
@@ -98,11 +104,23 @@ impl ProxyCtx {
recording: Arc::new(Mutex::new(RecordingLog::default())),
system_proxy_on: Arc::new(AtomicBool::new(false)),
start_error: Arc::new(Mutex::new(None)),
deleting_cleared: Arc::new(Mutex::new(false)),
cleaner_id: yaak_database::generate_prefixed_id("cln"),
db,
blobs,
events,
grpc: GrpcState::default(),
})
}
// A clear the last run did not finish deleting.
.with_cleared_deleting())
}
fn with_cleared_deleting(self) -> Self {
let pending = self.db.with_conn(|c| flows::clear_mark(c.conn().resolve())) > 0;
if pending {
self.delete_cleared();
}
self
}
/// The protoc that compiles `.proto` files attached for gRPC decoding, as the app bundles
@@ -596,7 +614,7 @@ impl ProxyCtx {
self.db
.with_tx(|tx| {
// Cleared while saving: recording it would bring the flow back without bodies.
if tx.find_optional::<Flow>(FlowIden::Id, flow_id).is_none() {
if flows::find_in(tx.conn().resolve(), flow_id).is_none() {
return Ok(());
}
let (flow, created) = tx.upsert(&flow, &UpdateSource::Background)?;
@@ -611,15 +629,89 @@ impl ProxyCtx {
Ok(saved)
}
/// Deletes every flow and its bodies, and tells the app so its list empties. Returns how
/// many there were.
/// Clears every flow at once and tells the app so its list empties, then deletes them and
/// their bodies in the background. Returns how many there were.
pub fn clear_flows(&self) -> Result<usize, String> {
let cleared = flows::clear(&self.db, &self.blobs)?;
let cleared = flows::clear(&self.db)?;
// After the commit, so the app reloads what is left. Flows written before the clear sent
// their events before it began.
self.events.emit("flows_cleared", &FlowsClearedPayload {});
self.delete_cleared();
Ok(cleared)
}
/// Deletes cleared flows on a background thread, a batch per transaction, unless one is
/// already doing so. Yields the writer between batches, so new flows are written meanwhile.
fn delete_cleared(&self) {
if !claim(&self.deleting_cleared) {
return;
}
let (db, blobs, running, cleaner) = (
self.db.clone(),
self.blobs.clone(),
self.deleting_cleared.clone(),
self.cleaner_id.clone(),
);
let spawned = std::thread::Builder::new().name("proxy-clear".into()).spawn(move || {
lower_thread_priority();
let mut retry = DELETE_PAUSE;
loop {
match flows::delete_cleared(&db, &blobs, flows::DELETE_BATCH, &cleaner) {
Ok(flows::Cleaned::More) => {
retry = DELETE_PAUSE;
std::thread::sleep(DELETE_PAUSE);
}
// Another process, the app or the CLI, is deleting them; checked again now
// and then, in case it stops before it is done.
Ok(flows::Cleaned::Elsewhere) => std::thread::sleep(CLEANER_WAIT),
Ok(flows::Cleaned::Done) => {
*running.lock().unwrap_or_else(PoisonError::into_inner) = false;
// A clear since the last batch saw this running and left its flows to
// it, so they are deleted here unless another thread has taken over.
if flows::clear_mark_in(&db) == 0 || !claim(&running) {
return;
}
}
// A busy database, most likely: tried again, waiting longer each time, so a
// clear is never left half done until the next launch.
Err(e) => {
warn!("{e}");
std::thread::sleep(retry);
retry = (retry * 4).min(DELETE_RETRY_MAX);
}
}
}
});
if let Err(e) = spawned {
warn!("Failed to start deleting cleared flows: {e}");
*self.deleting_cleared.lock().unwrap_or_else(PoisonError::into_inner) = false;
}
}
}
/// Sets `flag`, returning false when it was already set.
fn claim(flag: &Mutex<bool>) -> bool {
let mut set = flag.lock().unwrap_or_else(PoisonError::into_inner);
!std::mem::replace(&mut *set, true)
}
/// How long deleting cleared flows rests between batches, leaving the database to new flows.
const DELETE_PAUSE: Duration = Duration::from_millis(5);
/// The longest deleting cleared flows waits before trying a failed batch again.
const DELETE_RETRY_MAX: Duration = Duration::from_secs(5);
/// How often a process checks on cleared flows another process is deleting.
const CLEANER_WAIT: Duration = Duration::from_secs(15);
/// Runs the calling thread below the app's own work, where the platform has priorities: macOS's
/// utility class, not background, whose disk throttling can stall a delete for as long as the
/// Mac is busy.
fn lower_thread_priority() {
#[cfg(target_os = "macos")]
unsafe {
libc::pthread_set_qos_class_self_np(libc::qos_class_t::QOS_CLASS_UTILITY, 0);
}
}
/// Something accepts connections on `127.0.0.1:<port>`.
@@ -1399,8 +1491,10 @@ fn settings(ctx: &ProxyCtx) -> Result<ProxySettings, RpcError> {
ctx.settings().map_err(rpc_error)
}
/// A flow by id, with its number; not one that is cleared, though its row may wait to be
/// deleted.
fn find_flow(ctx: &ProxyCtx, flow_id: &str) -> Result<Flow, RpcError> {
ctx.db.with_conn(|db| db.find_one::<Flow>(FlowIden::Id, flow_id)).map_err(rpc_error)
flows::get(&ctx.db, flow_id).map_err(rpc_error)
}
fn rpc_error(e: impl ToString) -> RpcError {
@@ -1853,6 +1947,7 @@ impl FlowSink {
flow.client_name = Some(app.name);
flow.client_bundle_id = app.bundle_id;
flow.client_path = Some(app.path);
flow.client_cwd = app.cwd;
}
let persisted = f.persisted;
let written = self.db.with_tx(|ctx| self.write_flow(ctx, flow, persisted));
@@ -1879,6 +1974,7 @@ impl FlowSink {
flow.client_name = Some(app.name);
flow.client_bundle_id = app.bundle_id;
flow.client_path = Some(app.path);
flow.client_cwd = app.cwd;
}
let written = self.db.with_tx(|ctx| self.write_flow(ctx, flow, f.persisted));
match written {
@@ -1898,7 +1994,8 @@ impl FlowSink {
persisted: bool,
) -> Result<bool, yaak_database::Error> {
if persisted {
let Some(stored) = ctx.find_optional::<Flow>(FlowIden::Id, &flow.id) else {
// Not one cleared since, whose row may wait to be deleted.
let Some(stored) = flows::find_in(ctx.conn().resolve(), &flow.id) else {
return Ok(false);
};
flow.saved_request_id = stored.saved_request_id;
@@ -1926,8 +2023,14 @@ impl FlowSink {
}
}
/// The flow is stored and not cleared: a cleared one's row may wait to be deleted.
fn flow_exists(conn: &rusqlite::Connection, flow_id: &str) -> bool {
conn.query_row("SELECT 1 FROM flows WHERE id = ?1", [flow_id], |_| Ok(())).is_ok()
conn.query_row(
"SELECT 1 FROM flows WHERE id = ?1 AND rowid > ?2",
rusqlite::params![flow_id, flows::clear_mark(conn)],
|_| Ok(()),
)
.is_ok()
}
fn clamp_ms(ms: u64) -> i32 {
@@ -2176,8 +2279,28 @@ mod tests {
let names: Vec<_> = event_rx.try_iter().map(|e| e.event).collect();
assert_eq!(names.iter().filter(|n| **n == "message_write").count(), 3, "{names:?}");
// Gone from listings at once, and with its messages and their payloads once the
// background delete has run.
ctx.clear_flows().unwrap();
assert_eq!(flows::count(&ctx.db).unwrap(), 0);
wait_for_cleared(&ctx);
assert!(!ctx.blobs.connect().body_exists(&stored[1].blob_id()).unwrap());
let left: i64 = ctx.db.with_conn(|c| {
c.conn()
.resolve()
.query_row("SELECT count(*) FROM flow_messages", [], |r| r.get(0))
.unwrap()
});
assert_eq!(left, 0);
}
/// Waits for the background delete a clear starts to finish.
fn wait_for_cleared(ctx: &ProxyCtx) {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while ctx.db.with_conn(|c| flows::clear_mark(c.conn().resolve())) > 0 {
assert!(std::time::Instant::now() < deadline, "cleared flows were not deleted");
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
@@ -2204,6 +2327,9 @@ mod tests {
drop(tx);
event_loop.join().unwrap();
assert_eq!(flows::count(&ctx.db).unwrap(), 0, "not brought back by its end");
// The messages written before the clear go with the background delete.
wait_for_cleared(&ctx);
assert_eq!(flows::count(&ctx.db).unwrap(), 0, "not brought back by its end");
assert_eq!(messages::count(&ctx.db, &flow.id).unwrap(), 0);
let blobs = ctx.blobs.connect().list_body_ids().unwrap();
assert!(blobs.iter().all(|b| !b.starts_with(&flow.id)), "{blobs:?}");
+11
View File
@@ -23,6 +23,10 @@ pub struct ProxyHeader {
#[enum_def(table_name = "flows")]
pub struct Flow {
pub id: String,
/// The flow's short handle, `#1429`: its rowid, which grows as flows are written and is kept
/// across updates. Zero when read without it.
#[ts(type = "number")]
pub number: i64,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub started_at: NaiveDateTime,
@@ -66,6 +70,9 @@ pub struct Flow {
pub client_bundle_id: Option<String>,
/// Its app bundle's path, or its executable's outside an app.
pub client_path: Option<String>,
/// The working directory of a process outside an app, whose folder names it: `node` in
/// `fieldwork`.
pub client_cwd: Option<String>,
/// The flow carries a stream of messages, listed with `list_messages`. It is written as the
/// stream opens and again as messages land; it is open until `elapsedMs`, `error` or
/// `cancelled` is set.
@@ -222,6 +229,7 @@ impl UpsertModelInfo for Flow {
(ClientName, self.client_name.into()),
(ClientBundleId, self.client_bundle_id.into()),
(ClientPath, self.client_path.into()),
(ClientCwd, self.client_cwd.into()),
(FlowIden::StreamKind, self.stream_kind.map(|k| k.as_str()).into()),
(MessageCount, self.message_count.into()),
(GrpcService, self.grpc_service.into()),
@@ -257,6 +265,7 @@ impl UpsertModelInfo for Flow {
FlowIden::ClientName,
FlowIden::ClientBundleId,
FlowIden::ClientPath,
FlowIden::ClientCwd,
FlowIden::StreamKind,
FlowIden::MessageCount,
FlowIden::GrpcService,
@@ -274,6 +283,7 @@ impl UpsertModelInfo for Flow {
let response_headers: String = r.get("response_headers")?;
Ok(Self {
id: r.get("id")?,
number: added_column(r, "rowid")?.unwrap_or_default(),
created_at: r.get("created_at")?,
updated_at: r.get("updated_at")?,
started_at: r.get("started_at")?,
@@ -299,6 +309,7 @@ impl UpsertModelInfo for Flow {
client_name: added_column(r, "client_name")?,
client_bundle_id: added_column(r, "client_bundle_id")?,
client_path: added_column(r, "client_path")?,
client_cwd: added_column(r, "client_cwd")?,
stream_kind: added_column::<String>(r, "stream_kind")?
.and_then(|k| StreamKind::parse(&k)),
message_count: added_column(r, "message_count")?.unwrap_or_default(),
@@ -371,6 +371,12 @@ async fn grpc_web_calls_are_stored_and_decoded() {
// Clearing removes the messages with their flows.
rpc(&ctx, "clear_flows", json!({})).await.unwrap();
assert!(rpc(&ctx, "list_messages", json!({ "flowId": flow.id })).await.is_err());
// The rows go with the background delete the clear starts.
let deadline = Instant::now() + Duration::from_secs(10);
while ctx.db.with_conn(|c| yaak_proxy_lib::flows::clear_mark(c.conn().resolve())) > 0 {
assert!(Instant::now() < deadline, "cleared flows were not deleted");
tokio::time::sleep(Duration::from_millis(10)).await;
}
let left: i64 = ctx.db.with_conn(|c| {
c.conn()
.resolve()
+19
View File
@@ -259,6 +259,7 @@
"motion": "^14.8.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"tldts": "^7.4.19",
"vkbeautify": "^0.99.3"
},
"devDependencies": {
@@ -13217,6 +13218,24 @@
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "7.4.19",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz",
"integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==",
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.19"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.20",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz",
"integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==",
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -24,6 +24,7 @@ export function CountBadge({ count, count2, className, color, showZero }: Props)
color === "secondary" && "text-secondary",
color === "success" && "text-success",
color === "notice" && "text-notice",
color === "info" && "text-info",
color === "warning" && "text-warning",
color === "danger" && "text-danger",
)}
+6
View File
@@ -22,6 +22,7 @@ import {
BadgeCheckIcon,
BookmarkIcon,
BookOpenText,
BotIcon,
BoxIcon,
CakeIcon,
CheckCircleIcon,
@@ -95,6 +96,7 @@ import {
KeyRoundIcon,
KeyboardIcon,
ListXIcon,
LayersIcon,
LockIcon,
LockOpenIcon,
MergeIcon,
@@ -122,6 +124,7 @@ import {
ScissorsIcon,
SearchIcon,
SendHorizontalIcon,
Settings2Icon,
SettingsIcon,
ShieldAlertIcon,
ShieldCheckIcon,
@@ -169,6 +172,7 @@ const icons = {
badge_check: BadgeCheckIcon,
bookmark: BookmarkIcon,
book_open_text: BookOpenText,
bot: BotIcon,
box: BoxIcon,
cake: CakeIcon,
chat: MessageSquare,
@@ -245,6 +249,7 @@ const icons = {
keyboard: KeyboardIcon,
left_panel_hidden: PanelLeftOpenIcon,
left_panel_visible: PanelLeftCloseIcon,
layers: LayersIcon,
list_x: ListXIcon,
lock: LockIcon,
lock_open: LockOpenIcon,
@@ -271,6 +276,7 @@ const icons = {
search: SearchIcon,
send_horizontal: SendHorizontalIcon,
settings: SettingsIcon,
settings_2: Settings2Icon,
shield: ShieldIcon,
shield_check: ShieldCheckIcon,
shield_off: ShieldOffIcon,