mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
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:
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));
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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"} />
|
||||
)}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
{},
|
||||
);
|
||||
|
||||
@@ -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:")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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:?}");
|
||||
|
||||
@@ -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()
|
||||
|
||||
Generated
+19
@@ -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",
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user