- )}
- {help && }
-
- );
-}
+export type { CheckboxProps } from "@yaakapp-internal/ui";
+export { Checkbox } from "@yaakapp-internal/ui";
diff --git a/apps/yaak-client/components/core/Dialog.tsx b/apps/yaak-client/components/core/Dialog.tsx
index 00f546921..00076ec06 100644
--- a/apps/yaak-client/components/core/Dialog.tsx
+++ b/apps/yaak-client/components/core/Dialog.tsx
@@ -1,200 +1,2 @@
-import type { DialogSize } from "@yaakapp-internal/plugins";
-import { Heading, HStack, Overlay } from "@yaakapp-internal/ui";
-import classNames from "classnames";
-import * as m from "motion/react-m";
-import type { ReactNode } from "react";
-import { createContext, useContext, useMemo, useState } from "react";
-import { createPortal } from "react-dom";
-import { Button, type ButtonProps } from "./Button";
-import { IconButton } from "./IconButton";
-
-export interface DialogProps {
- children: ReactNode;
- open: boolean;
- onClose?: () => void;
- /** Block dismissal from the backdrop, Escape key, and built-in close button. */
- disableClose?: boolean;
- title?: ReactNode;
- description?: ReactNode;
- className?: string;
- size?: DialogSize;
- /** Hide the built-in close button without changing backdrop or Escape behavior. */
- hideX?: boolean;
- noPadding?: boolean;
- noScroll?: boolean;
- vAlign?: "top" | "center";
-}
-
-export function Dialog({
- children,
- className,
- size = "full",
- open,
- onClose,
- disableClose,
- title,
- description,
- hideX,
- noPadding,
- noScroll,
- vAlign = "center",
-}: DialogProps) {
- const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
- const [footerEl, setFooterEl] = useState(null);
- const descriptionId = useMemo(
- () => (description ? Math.random().toString(36).slice(2) : undefined),
- [description],
- );
-
- return (
-
-
{
- // NOTE: We handle Escape on the element itself so that it doesn't close multiple
- // dialogs and can be intercepted by children if needed.
- if (e.key === "Escape") {
- if (!disableClose) onClose?.();
- e.stopPropagation();
- e.preventDefault();
- }
- }}
- >
-
- {title ? (
-
- {title}
-
- ) : (
-
- )}
-
- {description ? (
-
- {description}
-
- ) : (
-
- )}
-
-
- {children}
-
-
-
-
- {/*Put close at the end so that it's the last thing to be tabbed to*/}
- {!disableClose && !hideX && (
-
-
-
- )}
-
-
-
- );
-}
-
-const DialogFooterSlot = createContext(null);
-
-export interface DialogFooterAction {
- label: string;
- onClick?: () => void;
- /** Submit the form with this id, which can be anywhere in the dialog body */
- form?: string;
- color?: ButtonProps["color"];
- variant?: ButtonProps["variant"];
- disabled?: boolean;
- isLoading?: boolean;
- autoFocus?: boolean;
- leftSlot?: ReactNode;
- rightSlot?: ReactNode;
-}
-
-/**
- * The action row at the bottom of a dialog. Render it anywhere in the dialog's content and it is
- * placed below the scrolling body, outside its padding. `inline` drops the divider for small
- * dialogs whose body never scrolls.
- */
-export function DialogFooter({
- actions,
- leftSlot,
- inline,
-}: {
- actions: DialogFooterAction[];
- leftSlot?: ReactNode;
- inline?: boolean;
-}) {
- const slot = useContext(DialogFooterSlot);
- const footer = (
-
- );
- return slot == null ? footer : createPortal(footer, slot);
-}
+export type { DialogFooterAction, DialogProps } from "@yaakapp-internal/ui";
+export { Dialog, DialogFooter } from "@yaakapp-internal/ui";
diff --git a/apps/yaak-client/components/core/PlainInput.tsx b/apps/yaak-client/components/core/PlainInput.tsx
index 473ab5663..17e865755 100644
--- a/apps/yaak-client/components/core/PlainInput.tsx
+++ b/apps/yaak-client/components/core/PlainInput.tsx
@@ -1,242 +1,2 @@
-import { HStack, useStateWithDeps, Label } from "@yaakapp-internal/ui";
-import classNames from "classnames";
-import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react";
-import {
- forwardRef,
- useCallback,
- useImperativeHandle,
- useLayoutEffect,
- useRef,
- useState,
-} from "react";
-import { useRandomKey } from "../../hooks/useRandomKey";
-import { IconButton } from "./IconButton";
-import type { InputProps } from "./Input";
-import { generateId } from "@yaakapp-internal/lib/generateId";
-
-export type PlainInputProps = Omit<
- InputProps,
- | "wrapLines"
- | "onKeyDown"
- | "type"
- | "stateKey"
- | "autocompleteVariables"
- | "autocompleteFunctions"
- | "autocomplete"
- | "extraExtensions"
- | "forcedEnvironmentId"
-> &
- Pick, "inputMode" | "onKeyDownCapture" | "step"> & {
- onFocusRaw?: InputHTMLAttributes["onFocus"];
- type?: "text" | "password" | "number";
- hideObscureToggle?: boolean;
- labelRightSlot?: ReactNode;
- };
-
-export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(function PlainInput(
- {
- autoFocus,
- autoSelect,
- className,
- containerClassName,
- defaultValue,
- disabled,
- forceUpdateKey: forceUpdateKeyFromAbove,
- help,
- hideLabel,
- hideObscureToggle,
- label,
- labelClassName,
- labelPosition = "top",
- labelRightSlot,
- inputMode,
- leftSlot,
- name,
- onBlur,
- onChange,
- onFocus,
- onFocusRaw,
- onKeyDownCapture,
- onPaste,
- placeholder,
- required,
- rightSlot,
- size = "md",
- step,
- tint,
- type = "text",
- validate,
- },
- ref,
-) {
- // Track a local key for updates. If the default value is changed when the input is not in focus,
- // regenerate this to force the field to update.
- const [focusedUpdateKey, regenerateFocusedUpdateKey] = useRandomKey();
- const forceUpdateKey = `${forceUpdateKeyFromAbove}::${focusedUpdateKey}`;
-
- const [obscured, setObscured] = useStateWithDeps(type === "password", [type]);
- const [focused, setFocused] = useState(false);
- const [hasChanged, setHasChanged] = useStateWithDeps(false, [forceUpdateKey]);
- const textareaRef = useRef(null);
- const inputRef = useRef(null);
-
- useImperativeHandle<{ focus: () => void } | null, { focus: () => void } | null>(
- ref,
- () => inputRef.current,
- );
-
- const handleFocus = useCallback(
- (e: FocusEvent) => {
- onFocusRaw?.(e);
- setFocused(true);
- if (autoSelect) {
- inputRef.current?.select();
- textareaRef.current?.select();
- }
- onFocus?.();
- },
- [autoSelect, onFocus, onFocusRaw],
- );
-
- const handleBlur = useCallback(() => {
- setFocused(false);
- onBlur?.();
- }, [onBlur]);
-
- // Force input to update when receiving change and not in focus
- useLayoutEffect(() => {
- const isFocused = document.activeElement === inputRef.current;
- if (defaultValue != null && !isFocused) {
- regenerateFocusedUpdateKey();
- }
- }, [regenerateFocusedUpdateKey, defaultValue]);
-
- const id = useRef(`input-${generateId()}`);
- const commonClassName = classNames(
- className,
- "bg-transparent! min-w-0 w-full focus:outline-hidden placeholder:text-placeholder",
- "px-2 text-xs font-mono cursor-text",
- );
-
- const handleChange = useCallback(
- (value: string) => {
- onChange?.(value);
- setHasChanged(true);
- const isValid = (value: string) => {
- if (required && !validateRequire(value)) return false;
- if (typeof validate === "boolean") return validate;
- if (typeof validate === "function" && !validate(value)) return false;
- return true;
- };
- inputRef.current?.setCustomValidity(isValid(value) ? "" : "Invalid value");
- },
- [onChange, required, setHasChanged, validate],
- );
-
- const wrapperRef = useRef(null);
-
- return (
-
-
- );
-}
+export type { ToastProps } from "@yaakapp-internal/ui";
+export { Toast } from "@yaakapp-internal/ui";
diff --git a/apps/yaak-client/hooks/useRandomKey.ts b/apps/yaak-client/hooks/useRandomKey.ts
index 9a73cf832..c3f42f26e 100644
--- a/apps/yaak-client/hooks/useRandomKey.ts
+++ b/apps/yaak-client/hooks/useRandomKey.ts
@@ -1,8 +1 @@
-import { useCallback, useState } from "react";
-import { generateId } from "@yaakapp-internal/lib/generateId";
-
-export function useRandomKey(initialValue?: string) {
- const [value, setValue] = useState(initialValue ?? generateId());
- const regenerate = useCallback(() => setValue(generateId()), []);
- return [value, regenerate] as const;
-}
+export { useRandomKey } from "@yaakapp-internal/ui";
diff --git a/apps/yaak-client/lib/resolvedModelName.ts b/apps/yaak-client/lib/resolvedModelName.ts
index 1b7dcfcfd..166c0145a 100644
--- a/apps/yaak-client/lib/resolvedModelName.ts
+++ b/apps/yaak-client/lib/resolvedModelName.ts
@@ -1,43 +1,9 @@
import type { AnyModel } from "@yaakapp-internal/models";
import { foldersAtom } from "@yaakapp-internal/models";
+import { resolvedModelName } from "@yaakapp-internal/ui/resolvedModelName";
import { jotaiStore } from "./jotai";
-import { replaceTemplateTags } from "./templateTags";
-export function resolvedModelName(r: AnyModel | null): string {
- if (r == null) return "";
-
- if (!("url" in r) || r.model === "plugin") {
- return "name" in r ? r.name : "";
- }
-
- // Return name if it has one
- if ("name" in r && r.name) {
- return r.name;
- }
-
- // Replace variable syntax with variable name
- const withoutVariables = replaceTemplateTags(r.url, (m) => m.inner.trim());
- if (withoutVariables.trim() === "") {
- return r.model === "http_request"
- ? r.bodyType && r.bodyType === "graphql"
- ? "GraphQL Request"
- : "HTTP Request"
- : r.model === "websocket_request"
- ? "WebSocket Request"
- : "gRPC Request";
- }
-
- // GRPC gets nice short names
- if (r.model === "grpc_request" && r.service != null && r.method != null) {
- const shortService = r.service.split(".").pop();
- return `${shortService}/${r.method}`;
- }
-
- // Strip unnecessary protocol
- const withoutProto = withoutVariables.replace(/^(http|https|ws|wss):\/\//, "");
-
- return withoutProto;
-}
+export { resolvedModelName };
export function resolvedModelNameWithFolders(model: AnyModel | null): string {
return resolvedModelNameWithFoldersArray(model).join(" / ");
diff --git a/apps/yaak-client/lib/templateTags.ts b/apps/yaak-client/lib/templateTags.ts
index b832321fc..b105ce627 100644
--- a/apps/yaak-client/lib/templateTags.ts
+++ b/apps/yaak-client/lib/templateTags.ts
@@ -1,137 +1,2 @@
-/*
- * Finding template tags in text.
- *
- * A tag closes at `]}`, but a quoted argument is allowed to contain `]}` of its own — the
- * printer in `crates/yaak-templates` emits `${[ fn(a='x]}y') ]}` verbatim — so the scan has to
- * step over quoted strings. It mirrors the Lezer grammar in
- * `components/core/Editor/twig/twig.grammar`: a terminated `'…'` (where `\` escapes whatever
- * follows it) is consumed whole, and an unterminated quote is just another character, so a
- * half-typed tag still closes at the next `]}` instead of swallowing the rest of the document.
- *
- * This was a regex until CodeQL flagged it (js/polynomial-redos): matching an unterminated
- * quote needed a negative lookahead that rescanned to the end of the input at every unpaired
- * quote, which is quadratic on text an attacker picks. The scan below is linear instead — see
- * the notes on `closingQuoteTable` and on the dead-end set in `findTemplateTags` for why.
- */
-
-const QUOTE = 0x27; // '
-const BACKSLASH = 0x5c; // \
-const CLOSE_BRACKET = 0x5d; // ]
-const CLOSE_BRACE = 0x7d; // }
-
-/** The `${[`…`]}` bounds of one tag, and what sits between them. */
-export interface TemplateTagMatch {
- /** Index of the `$` that opens the tag. */
- start: number;
- /** Index just past the closing `]}`. */
- end: number;
- /** Everything between `${[` and `]}`, not trimmed. */
- inner: string;
-}
-
-/**
- * For every index `i`, the index of the `'` that closes a string whose body starts at `i`, or
- * `-1` when nothing does.
- *
- * Built right to left in a single pass, which is the trick that replaces the regex lookahead:
- * whether a quote opens a terminated string depends only on the text after it, so the answer
- * can be computed once for every position instead of re-derived at each quote. Skipping a
- * string of any length then costs one lookup.
- */
-function closingQuoteTable(text: string): Int32Array {
- // Two extra slots so a `\` in the last position can read past the end and find `-1`
- const table = new Int32Array(text.length + 2).fill(-1);
- for (let i = text.length - 1; i >= 0; i--) {
- const c = text.charCodeAt(i);
- // A backslash escapes whatever follows it, including a quote
- const next = c === BACKSLASH ? table[i + 2] : table[i + 1];
- table[i] = c === QUOTE ? i : (next ?? -1);
- }
- return table;
-}
-
-/**
- * Finds every template tag in `text`, in order and without overlaps.
- *
- * A `${[` with no `]}` after it isn't a tag, and neither is a `${[` nested inside a tag body —
- * both are ordinary content, same as the regex this replaced treated them.
- */
-export function findTemplateTags(text: string): TemplateTagMatch[] {
- const matches: TemplateTagMatch[] = [];
-
- // Both built only once a scan actually needs them, so short values allocate nothing
- let closingQuotes: Int32Array | null = null;
-
- /*
- * Body positions a previous scan already ran off the end of the text from. Where a scan ends
- * up depends only on where it starts, so a scan that walks into one of these would repeat
- * that same walk and fail the same way, and can stop right there.
- *
- * That bound is what keeps the whole pass linear: every position enters this set at most
- * once, and every step of every scan either visits a position for the first time or ends
- * that scan.
- */
- let deadEnds: Set | null = null;
-
- let searchFrom = 0;
- while (searchFrom < text.length) {
- const start = text.indexOf("${[", searchFrom);
- if (start < 0) break;
-
- const visited: number[] = [];
- let end = -1;
- let i = start + 3;
- while (i < text.length) {
- if (deadEnds?.has(i)) break;
- visited.push(i);
-
- const c = text.charCodeAt(i);
- if (c === QUOTE) {
- closingQuotes ??= closingQuoteTable(text);
- const close = closingQuotes[i + 1] ?? -1;
- // An unterminated quote is an ordinary character, so only step over a closed string
- i = close < 0 ? i + 1 : close + 1;
- continue;
- }
-
- if (c === CLOSE_BRACKET && text.charCodeAt(i + 1) === CLOSE_BRACE) {
- end = i + 2;
- break;
- }
-
- i += 1;
- }
-
- if (end < 0) {
- // Not a tag. Resume past the opener: a real tag may start inside a string this scan
- // stepped over.
- deadEnds ??= new Set();
- for (const position of visited) deadEnds.add(position);
- searchFrom = start + 3;
- continue;
- }
-
- matches.push({ start, end, inner: text.slice(start + 3, end - 2) });
- searchFrom = end;
- }
-
- return matches;
-}
-
-/** Rewrites every template tag in `text` with whatever `replace` returns for it. */
-export function replaceTemplateTags(
- text: string,
- replace: (match: TemplateTagMatch) => string,
-): string {
- const matches = findTemplateTags(text);
- if (matches.length === 0) return text;
-
- let result = "";
- let copiedTo = 0;
- for (const match of matches) {
- result += text.slice(copiedTo, match.start) + replace(match);
- copiedTo = match.end;
- }
-
- return result + text.slice(copiedTo);
-}
+export type { TemplateTagMatch } from "@yaakapp-internal/ui/templateTags";
+export { findTemplateTags, replaceTemplateTags } from "@yaakapp-internal/ui/templateTags";
diff --git a/apps/yaak-client/lib/toast.tsx b/apps/yaak-client/lib/toast.tsx
index 26678d544..737fdec04 100644
--- a/apps/yaak-client/lib/toast.tsx
+++ b/apps/yaak-client/lib/toast.tsx
@@ -1,50 +1,25 @@
-import { atom } from "jotai";
-import type { ToastInstance } from "../components/Toasts";
-import { generateId } from "@yaakapp-internal/lib/generateId";
+// The toast store alone, without the components, which tests in a plain runtime can't load
+import {
+ hideToast as hideToastIn,
+ hideToastById as hideToastByIdIn,
+ type ShowToastArgs,
+ showToast as showToastIn,
+ type ToastInstance,
+} from "@yaakapp-internal/ui/toast";
import { jotaiStore } from "./jotai";
-export const toastsAtom = atom([]);
+export { toastsAtom } from "@yaakapp-internal/ui/toast";
-export function showToast({
- id,
- timeout = 5000,
- ...props
-}: Omit & {
- id?: ToastInstance["id"];
- timeout?: ToastInstance["timeout"];
-}) {
- id = id ?? generateId();
- const uniqueKey = generateId();
-
- const toasts = jotaiStore.get(toastsAtom);
- const toastWithSameId = toasts.find((t) => t.id === id);
-
- let delay = 0;
- if (toastWithSameId) {
- hideToast(toastWithSameId);
- // Allow enough time for old toast to animate out
- delay = 200;
- }
-
- setTimeout(() => {
- const newToast: ToastInstance = { id, uniqueKey, timeout, ...props };
- jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]);
- }, delay);
-
- return id;
+export function showToast(args: ShowToastArgs) {
+ return showToastIn(jotaiStore, args);
}
export function hideToastById(id: string) {
- const toast = jotaiStore.get(toastsAtom).find((t) => t.id === id);
- if (toast) hideToast(toast);
+ hideToastByIdIn(jotaiStore, id);
}
export function hideToast(toHide: ToastInstance) {
- jotaiStore.set(toastsAtom, (all) => {
- const t = all.find((t) => t.uniqueKey === toHide.uniqueKey);
- t?.onClose?.();
- return all.filter((t) => t.uniqueKey !== toHide.uniqueKey);
- });
+ hideToastIn(jotaiStore, toHide);
}
export function showErrorToast({
diff --git a/apps/yaak-proxy/components/AppIcon.tsx b/apps/yaak-proxy/components/AppIcon.tsx
new file mode 100644
index 000000000..e132ee021
--- /dev/null
+++ b/apps/yaak-proxy/components/AppIcon.tsx
@@ -0,0 +1,49 @@
+import classNames from "classnames";
+import { useEffect, useState } from "react";
+import { rpc } from "../lib/rpc";
+
+/**
+ * Icons by app path, as data URLs once loaded, for the whole session. Plain rather than a query
+ * per icon, since every new row in the list shows one.
+ */
+const icons = new Map();
+const loading = new Map>();
+
+function loadIcon(path: string): Promise {
+ let promise = loading.get(path);
+ if (promise == null) {
+ promise = rpc("app_icon", { path })
+ .then((png) => (png == null ? null : `data:image/png;base64,${png}`))
+ .catch(() => null)
+ .then((url) => {
+ icons.set(path, url);
+ return url;
+ });
+ loading.set(path, promise);
+ }
+ return promise;
+}
+
+/** An app's icon, read once per path for the whole session */
+export function AppIcon({ path, className }: { path: string; className?: string }) {
+ const [url, setUrl] = useState(() => icons.get(path));
+ useEffect(() => {
+ if (icons.has(path)) {
+ setUrl(icons.get(path));
+ return;
+ }
+ let current = true;
+ void loadIcon(path).then((loaded) => {
+ if (current) setUrl(loaded);
+ });
+ return () => {
+ current = false;
+ };
+ }, [path]);
+
+ return url == null ? (
+
+ ) : (
+
+ );
+}
diff --git a/apps/yaak-proxy/components/FirstRun.tsx b/apps/yaak-proxy/components/FirstRun.tsx
new file mode 100644
index 000000000..987bb07b4
--- /dev/null
+++ b/apps/yaak-proxy/components/FirstRun.tsx
@@ -0,0 +1,225 @@
+import { Button, Icon, LoadingIcon } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import { useAtomValue } from "jotai";
+import type { ReactNode } from "react";
+import { useRpcMutation } from "../hooks/useRpcMutation";
+import { proxyStateAtom } from "../lib/store";
+import { useCopyEnvironment, useProxySetup, useSystemProxySwitch } from "./ProxyStatus";
+
+type StepState = "todo" | "next" | "wait" | "done" | "fail";
+
+/**
+ * The list's empty state until traffic arrives: trust the certificate, turn on the system proxy,
+ * then see traffic. Not dismissable and nothing remembered; the first flow replaces it. Copy
+ * environment beside it serves someone who wants one terminal proxied and nothing system-wide.
+ */
+export function FirstRun() {
+ const proxyState = useAtomValue(proxyStateAtom);
+ const setup = useProxySetup();
+ const { caStatus, trust, systemProxy, setSystemProxy } = setup;
+ const systemSwitch = useSystemProxySwitch(setup);
+ const execute = useRpcMutation("execute_action");
+ const retry = useRpcMutation("take_over");
+ const copyEnvironment = useCopyEnvironment();
+
+ const running = proxyState?.state === "running";
+ // Changed only by the process that owns the engine, so not from here while the CLI does.
+ const otherOwner = proxyState?.otherOwner != null;
+ const recording = proxyState?.recording !== false;
+ const trusted = caStatus.data?.trusted === true;
+ const systemSupported = systemProxy.data?.supported !== false;
+ const systemOn = systemProxy.data?.enabled === true;
+
+ const trustState: StepState = trusted
+ ? "done"
+ : trust.isPending
+ ? "wait"
+ : trust.error != null
+ ? "fail"
+ : "next";
+ const systemState: StepState = setSystemProxy.isPending
+ ? "wait"
+ : systemOn
+ ? "done"
+ : systemSwitch.failed
+ ? "fail"
+ : trusted
+ ? "next"
+ : "todo";
+ const ready = trusted && (systemOn || !systemSupported);
+ const trafficState: StepState = !running && !otherOwner ? "fail" : ready ? "wait" : "todo";
+
+ return (
+
+
+
Set up Yaak Proxy
+
+ Three steps, once. Afterwards every app on this Mac sends its traffic here, and HTTPS
+ reads as plain text.
+
@@ -214,9 +221,9 @@ function ItemInner({ item }: { item: SidebarItem }) {
export function Sidebar() {
const tree = useAtomValue(sidebarTreeAtom);
- const treeId = SIDEBAR_TREE_ID;
const getItemKey = useCallback((item: SidebarItem) => `${item.id}:${item.count}`, []);
+ const treeId = SIDEBAR_TREE_ID;
return (
// The tree scrolls itself, in a row of bounded height, so its virtualizer mounts only the rows
diff --git a/apps/yaak-proxy/components/TakeOverBanner.tsx b/apps/yaak-proxy/components/TakeOverBanner.tsx
new file mode 100644
index 000000000..fefb1b98c
--- /dev/null
+++ b/apps/yaak-proxy/components/TakeOverBanner.tsx
@@ -0,0 +1,52 @@
+import { Button, Icon } from "@yaakapp-internal/ui";
+import { useAtomValue } from "jotai";
+import { useRpcMutation } from "../hooks/useRpcMutation";
+import { proxyStateAtom } from "../lib/store";
+
+/**
+ * A quiet strip while another process, the CLI daemon, owns the engine: flows still list, read
+ * from the database, and Take over stops that process's proxy and starts this app's. Never done
+ * without the person asking.
+ */
+export function TakeOverBanner() {
+ const other = useAtomValue(proxyStateAtom)?.otherOwner;
+ const takeOver = useRpcMutation("take_over");
+ if (other == null) return null;
+ const since = new Date(other.startedAt).toLocaleTimeString([], {
+ hour: "numeric",
+ minute: "2-digit",
+ });
+ const cli = other.owner === "cli";
+ const who = cli ? "Running from the CLI" : "Running in another Yaak Proxy";
+
+ return (
+
+
+ {who}
+ {other.owner === "cli" && (
+ yaak proxy start
+ )}
+
+ {!cli ? (
+ // Stopping another app halts its engine without releasing it, so it can't be taken over.
+ "Another Yaak Proxy window owns the engine. Quit it to use this one."
+ ) : takeOver.error != null ? (
+ {takeOver.error.message}
+ ) : (
+ `pid ${other.pid}, since ${since}. Flows still list here, read from the capture database.`
+ )}
+
+ {cli && (
+
+ )}
+
+ );
+}
diff --git a/apps/yaak-proxy/lib/atoms.ts b/apps/yaak-proxy/lib/atoms.ts
index bfe87669e..040fbe629 100644
--- a/apps/yaak-proxy/lib/atoms.ts
+++ b/apps/yaak-proxy/lib/atoms.ts
@@ -3,6 +3,9 @@ import type { SplitLayoutLayout } from "@yaakapp-internal/ui";
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
+/** The sidebar's tree, whose selection narrows the list */
+export const SIDEBAR_TREE_ID = "proxy-sidebar";
+
/** The selected flow as last seen, kept once the window has dropped it so the detail stays */
export const selectedFlowAtom = atom(null);
diff --git a/apps/yaak-proxy/lib/curl.test.ts b/apps/yaak-proxy/lib/curl.test.ts
new file mode 100644
index 000000000..5c04a5c23
--- /dev/null
+++ b/apps/yaak-proxy/lib/curl.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, test } from "vite-plus/test";
+import { curlBodyText, shellQuote, toCurl } from "./curl";
+
+const headers = (...pairs: [string, string][]) => pairs.map(([name, value]) => ({ name, value }));
+
+describe("toCurl", () => {
+ test("a GET is the URL and its headers, without the ones curl sets itself", () => {
+ const flow = {
+ method: "GET",
+ url: "https://api.github.com/repos/yaak?page=2",
+ requestHeaders: headers(
+ ["Host", "api.github.com"],
+ ["Accept", "application/json"],
+ ["Proxy-Connection", "keep-alive"],
+ ["Connection", "keep-alive"],
+ ),
+ };
+ expect(toCurl(flow, null)).toBe(
+ "curl 'https://api.github.com/repos/yaak?page=2' \\\n -H 'Accept: application/json'",
+ );
+ });
+
+ test("a POST with a body leaves the method to curl and quotes the body", () => {
+ const flow = {
+ method: "POST",
+ url: "https://api.stripe.com/v1/charges",
+ requestHeaders: headers(
+ ["Authorization", "Bearer sk_test_123"],
+ ["Content-Type", "application/json"],
+ ["Content-Length", "23"],
+ ["Accept-Encoding", "gzip, br"],
+ ),
+ };
+ expect(toCurl(flow, `{"note":"it's paid"}`)).toBe(
+ [
+ "curl 'https://api.stripe.com/v1/charges'",
+ "-H 'Authorization: Bearer sk_test_123'",
+ "-H 'Content-Type: application/json'",
+ "--compressed",
+ `--data-raw '{"note":"it'\\''s paid"}'`,
+ ].join(" \\\n "),
+ );
+ });
+
+ test("quotes a method that is not a plain word, and drops the decoded body's encoding", () => {
+ const flow = {
+ method: "`id`",
+ url: "https://x.test/a",
+ requestHeaders: headers(["Content-Encoding", "gzip"], ["X-Trace", "1"]),
+ };
+ expect(toCurl(flow, "plain")).toBe(
+ "curl -X '`id`' \\\n 'https://x.test/a' \\\n -H 'X-Trace: 1' \\\n --data-raw 'plain'",
+ );
+ });
+
+ test("replays HEAD with --head and leaves brackets in the URL alone", () => {
+ const flow = {
+ method: "HEAD",
+ url: "https://x.test/a?filter[status]=open",
+ requestHeaders: [],
+ };
+ expect(toCurl(flow, null)).toBe(
+ "curl --head \\\n --globoff \\\n 'https://x.test/a?filter[status]=open'",
+ );
+ });
+
+ test("names any other method", () => {
+ const flow = { method: "DELETE", url: "https://x.test/a", requestHeaders: [] };
+ expect(toCurl(flow, null)).toBe("curl -X DELETE \\\n 'https://x.test/a'");
+ expect(toCurl({ ...flow, method: "GET" }, "q=1")).toBe(
+ "curl -X GET \\\n 'https://x.test/a' \\\n --data-raw 'q=1'",
+ );
+ });
+});
+
+describe("shellQuote", () => {
+ test("closes and reopens the quotes around a quote", () => {
+ expect(shellQuote("a'b")).toBe(`'a'\\''b'`);
+ expect(shellQuote("$HOME `x`")).toBe("'$HOME `x`'");
+ });
+});
+
+describe("curlBodyText", () => {
+ test("is the text of a complete UTF-8 body", () => {
+ expect(curlBodyText(new TextEncoder().encode('{"note":"héllo"}'), false)).toBe(
+ '{"note":"héllo"}',
+ );
+ });
+
+ test("refuses a body curl would send different bytes for", () => {
+ expect(curlBodyText(new Uint8Array([0xff, 0x00, 0xfe]), false)).toBeNull();
+ expect(curlBodyText(new TextEncoder().encode("the first part"), true)).toBeNull();
+ });
+});
diff --git a/apps/yaak-proxy/lib/curl.ts b/apps/yaak-proxy/lib/curl.ts
new file mode 100644
index 000000000..2327e42cd
--- /dev/null
+++ b/apps/yaak-proxy/lib/curl.ts
@@ -0,0 +1,68 @@
+import type { Flow } from "@yaakapp-internal/proxy-lib";
+
+/** Headers curl works out itself, or that only meant something between the app and the proxy */
+const LEFT_OUT = new Set([
+ "host",
+ "content-length",
+ "connection",
+ "keep-alive",
+ "te",
+ "trailer",
+ "transfer-encoding",
+ "upgrade",
+]);
+
+/**
+ * A request body as the text a curl command can carry, or null when it would not send the same
+ * bytes: when it is not valid UTF-8, or only its start was captured.
+ */
+export function curlBodyText(bytes: Uint8Array, truncated: boolean): string | null {
+ if (truncated) return null;
+ try {
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
+ } catch {
+ return null;
+ }
+}
+
+/** `value` in single quotes for a POSIX shell */
+export function shellQuote(value: string): string {
+ return `'${value.replaceAll("'", `'\\''`)}'`;
+}
+
+/**
+ * The flow's request as a curl command: its method, URL, headers and `body`, the request body
+ * decoded. Headers curl sets itself are left out, and an encoded response is asked for with
+ * `--compressed`, as the app did.
+ */
+export function toCurl(flow: Pick, body: string | null) {
+ const args: string[] = [];
+ const hasBody = body != null && body !== "";
+ const defaultMethod = hasBody ? "POST" : "GET";
+ if (flow.method.toUpperCase() === "HEAD" && !hasBody) {
+ // So curl expects no body after the headers.
+ args.push("--head");
+ } else if (flow.method.toUpperCase() !== defaultMethod) {
+ // A method is any token, which can hold characters a shell would act on.
+ const plain = /^[A-Za-z0-9_-]+$/.test(flow.method);
+ args.push(`-X ${plain ? flow.method : shellQuote(flow.method)}`);
+ }
+ // curl reads brackets and braces in a URL as ranges and sets unless told not to.
+ if (/[[\]{}]/.test(flow.url)) args.push("--globoff");
+ args.push(shellQuote(flow.url));
+ let compressed = false;
+ for (const { name, value } of flow.requestHeaders) {
+ const lower = name.toLowerCase();
+ if (LEFT_OUT.has(lower) || lower.startsWith("proxy-") || lower.startsWith(":")) continue;
+ // The body is the decoded one, so its encoding no longer applies.
+ if (lower === "content-encoding" && hasBody) continue;
+ if (lower === "accept-encoding") {
+ compressed = true;
+ continue;
+ }
+ args.push(`-H ${shellQuote(`${name}: ${value}`)}`);
+ }
+ if (compressed) args.push("--compressed");
+ if (hasBody) args.push(`--data-raw ${shellQuote(body)}`);
+ return `curl ${args.join(" \\\n ")}`;
+}
diff --git a/apps/yaak-proxy/lib/env.test.ts b/apps/yaak-proxy/lib/env.test.ts
new file mode 100644
index 000000000..0bd24dd08
--- /dev/null
+++ b/apps/yaak-proxy/lib/env.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, test } from "vite-plus/test";
+import { environmentExports } from "./env";
+
+describe("environmentExports", () => {
+ test("points the proxy variables at the port and the CA variables at the certificate", () => {
+ const path = "/Users/me/Library/Application Support/app.yaak.proxy/ca/ca.pem";
+ expect(environmentExports(9090, path)).toBe(
+ [
+ "export HTTP_PROXY=http://127.0.0.1:9090",
+ "export HTTPS_PROXY=http://127.0.0.1:9090",
+ "export http_proxy=http://127.0.0.1:9090",
+ "export https_proxy=http://127.0.0.1:9090",
+ `export SSL_CERT_FILE='${path}'`,
+ `export NODE_EXTRA_CA_CERTS='${path}'`,
+ `export REQUESTS_CA_BUNDLE='${path}'`,
+ ].join("\n"),
+ );
+ });
+});
diff --git a/apps/yaak-proxy/lib/env.ts b/apps/yaak-proxy/lib/env.ts
new file mode 100644
index 000000000..1b7511fa7
--- /dev/null
+++ b/apps/yaak-proxy/lib/env.ts
@@ -0,0 +1,21 @@
+import { shellQuote } from "./curl";
+
+/**
+ * Shell exports that send one terminal's traffic through the proxy on `port` and trust its CA at
+ * `caPath`, for tools that read neither the system proxy nor the login keychain: Node, Python,
+ * and anything built on OpenSSL.
+ */
+export function environmentExports(port: number, caPath: string): string {
+ const proxy = `http://127.0.0.1:${port}`;
+ const ca = shellQuote(caPath);
+ return [
+ `export HTTP_PROXY=${proxy}`,
+ `export HTTPS_PROXY=${proxy}`,
+ // curl reads only the lowercase name for plain HTTP.
+ `export http_proxy=${proxy}`,
+ `export https_proxy=${proxy}`,
+ `export SSL_CERT_FILE=${ca}`,
+ `export NODE_EXTRA_CA_CERTS=${ca}`,
+ `export REQUESTS_CA_BUNDLE=${ca}`,
+ ].join("\n");
+}
diff --git a/apps/yaak-proxy/lib/flowActions.ts b/apps/yaak-proxy/lib/flowActions.ts
new file mode 100644
index 000000000..4c91d67fc
--- /dev/null
+++ b/apps/yaak-proxy/lib/flowActions.ts
@@ -0,0 +1,121 @@
+import type { ExportHarRequest, Flow, FlowBody } from "@yaakapp-internal/proxy-lib";
+import type { DropdownItem } from "@yaakapp-internal/ui";
+import { save } from "@tauri-apps/plugin-dialog";
+import { atom, useSetAtom } from "jotai";
+import { useCallback } from "react";
+import { base64ToBytes } from "./body";
+import { curlBodyText, toCurl } from "./curl";
+import { rpc } from "./rpc";
+import { useShowToast } from "./toast";
+
+/** The flow the save dialog is open for */
+export const saveDialogFlowAtom = atom(null);
+
+/** A body as text, decoding what is not UTF-8 lossily, or null when there is none */
+async function bodyText(flow: Flow, which: FlowBody): Promise {
+ const res = await rpc("get_flow_body", { flowId: flow.id, which });
+ const bytes = base64ToBytes(res.data);
+ return bytes.length === 0 ? null : new TextDecoder().decode(bytes);
+}
+
+/**
+ * Writes flows to a HAR file the person picks: `ids`, or every flow `filter` covers. Returns how
+ * many were written, or null when the save panel was cancelled.
+ */
+export async function exportHar(
+ req: Omit,
+ defaultName: string,
+): Promise {
+ const path = await save({
+ defaultPath: defaultName,
+ filters: [{ name: "HTTP Archive", extensions: ["har"] }],
+ });
+ if (path == null) return null;
+ return rpc("export_har", { ...req, path });
+}
+
+/** What a flow's context menu and the detail pane's menu offer */
+export function useFlowActions(): (flow: Flow) => DropdownItem[] {
+ const showToast = useShowToast();
+ const openSave = useSetAtom(saveDialogFlowAtom);
+
+ return useCallback(
+ (flow: Flow) => {
+ const copy = async (label: string, text: () => Promise | string) => {
+ try {
+ const value = await text();
+ if (value == null) {
+ showToast({ message: `There is no ${label} to copy`, color: "info" });
+ return;
+ }
+ await navigator.clipboard.writeText(value);
+ showToast({ message: `Copied ${label}` });
+ } catch (err) {
+ showToast({ message: `Failed to copy ${label}: ${String(err)}`, color: "danger" });
+ }
+ };
+ const noResponse = flow.error != null && flow.status == null;
+ return [
+ {
+ label: flow.savedRequestId == null ? "Save as Yaak Request" : "Save to Yaak Again",
+ icon: "bookmark",
+ onSelect: () => openSave(flow),
+ },
+ { type: "separator" },
+ {
+ label: "Copy as cURL",
+ icon: "square_terminal",
+ onSelect: async () => {
+ let body: string | null = null;
+ if (flow.requestSize > 0) {
+ let res;
+ try {
+ res = await rpc("get_flow_body", { flowId: flow.id, which: "request" });
+ } catch (err) {
+ showToast({ message: `Failed to copy cURL: ${String(err)}`, color: "danger" });
+ return;
+ }
+ body = curlBodyText(base64ToBytes(res.data), res.truncated);
+ // A command that sends other bytes than the app did would mislead.
+ if (body == null) {
+ showToast({
+ message: "Body is binary or truncated; export HAR instead",
+ color: "info",
+ });
+ return;
+ }
+ }
+ await copy("cURL", () => toCurl(flow, body));
+ },
+ },
+ { label: "Copy URL", icon: "copy", onSelect: () => copy("URL", () => flow.url) },
+ {
+ label: "Copy Request Body",
+ icon: "copy",
+ disabled: flow.requestSize === 0,
+ onSelect: () => copy("request body", () => bodyText(flow, "request")),
+ },
+ {
+ label: "Copy Response Body",
+ icon: "copy",
+ disabled: noResponse,
+ onSelect: () => copy("response body", () => bodyText(flow, "response")),
+ },
+ { type: "separator" },
+ {
+ label: "Export HAR",
+ icon: "download",
+ onSelect: async () => {
+ try {
+ const written = await exportHar({ ids: [flow.id] }, "flow.har");
+ if (written != null) showToast({ message: "Exported the flow as HAR" });
+ } catch (err) {
+ showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" });
+ }
+ },
+ },
+ ];
+ },
+ [openSave, showToast],
+ );
+}
diff --git a/apps/yaak-proxy/lib/search.test.ts b/apps/yaak-proxy/lib/search.test.ts
new file mode 100644
index 000000000..5b60763f7
--- /dev/null
+++ b/apps/yaak-proxy/lib/search.test.ts
@@ -0,0 +1,143 @@
+import type { Flow } from "@yaakapp-internal/proxy-lib";
+import { describe, expect, test } from "vite-plus/test";
+import { matchesQuery, searchTerms, selectionQuery, sidebarPath } from "./search";
+
+function flow(url: string, extra: Partial = {}): Flow {
+ return {
+ url,
+ method: "GET",
+ status: 200,
+ contentType: null,
+ clientBundleId: null,
+ clientPath: null,
+ ...extra,
+ } as Flow;
+}
+
+// The same flows and terms as `search_matches_url_method_status_and_content_type` in
+// `flows.rs`, so the live matcher and the database agree.
+const stripe = flow("https://api.stripe.com/v1/charges?customer=cus_9", {
+ method: "POST",
+ contentType: "application/json; charset=utf-8",
+});
+const github = flow("https://api.github.com/repos/yaak", {
+ status: 404,
+ contentType: "application/json; charset=utf-8",
+});
+const logo = flow("https://images.ctfassets.net/logo.png", { contentType: "image/png" });
+const health = flow("http://localhost:3000/health", { method: "HEAD", status: 503 });
+const all = [stripe, github, logo, health];
+
+const search = (q: string) => all.filter((f) => matchesQuery(f, { q }));
+
+describe("matchesQuery", () => {
+ test("matches the host, the path and the query, any case", () => {
+ expect(search("STRIPE")).toEqual([stripe]);
+ expect(search("/repos/")).toEqual([github]);
+ expect(search("cus_9")).toEqual([stripe]);
+ });
+
+ test("matches the whole method", () => {
+ expect(search("head")).toEqual([health]);
+ expect(search("ead")).toEqual([]);
+ });
+
+ test("matches a status, its start, or its class", () => {
+ expect(search("404")).toEqual([github]);
+ expect(search("50")).toEqual([health]);
+ expect(search("2xx")).toEqual([stripe, logo]);
+ });
+
+ test("matches the content type", () => {
+ expect(search("png")).toEqual([logo]);
+ expect(search("json")).toEqual([stripe, github]);
+ });
+
+ test("needs every term to match", () => {
+ expect(search("json get")).toEqual([github]);
+ expect(search(" json 2xx ")).toEqual([stripe]);
+ expect(search("json 5xx")).toEqual([]);
+ expect(search("")).toEqual(all);
+ });
+
+ test("keeps flows under a host or path, as the sidebar places them", () => {
+ const under = (node: string, url: string) => matchesQuery(flow(url), { under: [node] });
+ expect(under("api.stripe.com", "https://api.stripe.com/v1/charges")).toBe(true);
+ expect(under("api.stripe.com", "http://api.stripe.com")).toBe(true);
+ // Another port is a host node of its own, as in the sidebar.
+ expect(under("api.stripe.com", "https://api.stripe.com:8443/v1")).toBe(false);
+ // Empty segments and default ports are left out, as the sidebar leaves them.
+ expect(under("example.com/v1/users", "https://example.com/v1//users")).toBe(true);
+ expect(under("x.com/a", "http://x.com:80/a")).toBe(true);
+ expect(under("api.stripe.com", "https://api.stripe.community/")).toBe(false);
+ expect(under("api.stripe.com/v1/charges", "https://api.stripe.com/v1/charges/ch_1?x")).toBe(
+ true,
+ );
+ expect(under("api.stripe.com/v1/charges", "https://api.stripe.com/v1/chargesx")).toBe(false);
+ expect(under("api.stripe.com:8443", "https://api.stripe.com/v1")).toBe(false);
+ // The sidebar lowercases hosts; paths keep their case.
+ expect(under("example.com/A", "http://EXAMPLE.com/A/b")).toBe(true);
+ expect(under("example.com/a", "http://example.com/A")).toBe(false);
+ });
+
+ test("keeps flows from an app, by bundle id or path", () => {
+ const chrome = flow("https://example.com/", {
+ clientBundleId: "com.google.Chrome",
+ clientPath: "/Applications/Google Chrome.app",
+ });
+ const curl = flow("https://example.com/", { clientPath: "/usr/bin/curl" });
+ const unknown = flow("https://example.com/");
+ const from = (app: string[]) => [chrome, curl, unknown].filter((f) => matchesQuery(f, { app }));
+ expect(from(["com.google.Chrome"])).toEqual([chrome]);
+ expect(from(["/usr/bin/curl"])).toEqual([curl]);
+ expect(from(["/Applications/Google Chrome.app"])).toEqual([]);
+ });
+
+ test("combines terms with the sidebar's selection", () => {
+ expect(matchesQuery(stripe, { q: "post", under: ["api.stripe.com"] })).toBe(true);
+ expect(matchesQuery(stripe, { q: "post", under: ["api.github.com"] })).toBe(false);
+ });
+});
+
+describe("sidebarPath", () => {
+ // The cases `sidebar_path_rules` in `flows.rs` checks, so the two agree.
+ test("is the lowercased host without its default port, then the non-empty segments", () => {
+ for (const [url, path] of [
+ ["https://example.com/v1//users", "example.com/v1/users"],
+ ["http://x.com:80/a", "x.com/a"],
+ ["https://x.com:443/a?q=1#f", "x.com/a"],
+ ["http://x.com:443/a", "x.com:443/a"],
+ ["https://API.Stripe.com:8443/", "api.stripe.com:8443"],
+ ["https://api.stripe.com", "api.stripe.com"],
+ ["https://api.stripe.com?x=/y", "api.stripe.com"],
+ ["http://[::1]:80/a/B", "[::1]/a/B"],
+ ["/api", "/api"],
+ ]) {
+ expect(sidebarPath(url ?? ""), url).toBe(path);
+ }
+ });
+});
+
+describe("selectionQuery", () => {
+ test("is null for nothing selected or a node holding every flow", () => {
+ expect(selectionQuery([])).toBeNull();
+ expect(selectionQuery(["root"])).toBeNull();
+ expect(selectionQuery(["domain:a.com", "domains"])).toBeNull();
+ expect(selectionQuery(["apps"])).toBeNull();
+ });
+
+ test("splits hosts and paths from apps", () => {
+ expect(selectionQuery(["domain:a.com", "path:b.com/v1", "app:com.apple.Safari"])).toEqual({
+ under: ["a.com", "b.com/v1"],
+ app: ["com.apple.Safari"],
+ });
+ expect(selectionQuery(["app:/usr/bin/curl"])).toEqual({ app: ["/usr/bin/curl"] });
+ });
+});
+
+describe("searchTerms", () => {
+ test("trims and collapses the box's text, or is null without any", () => {
+ expect(searchTerms(" stripe post ")).toBe("stripe post");
+ expect(searchTerms(" ")).toBeNull();
+ });
+});
diff --git a/apps/yaak-proxy/lib/search.ts b/apps/yaak-proxy/lib/search.ts
new file mode 100644
index 000000000..4260b58c9
--- /dev/null
+++ b/apps/yaak-proxy/lib/search.ts
@@ -0,0 +1,116 @@
+import type { Flow, FlowFilter } from "@yaakapp-internal/proxy-lib";
+
+/**
+ * A search over every flow in the database: the search box's terms and the sidebar's selection,
+ * as `list_flows` takes them.
+ */
+export type FlowQuery = Pick;
+
+/** The sidebar node for all of an app's flows */
+export function appNodeId(key: string): string {
+ return `app:${key}`;
+}
+
+/** What groups an app's flows: its bundle id, or its path for a process outside an app */
+export function appKeyOf(flow: Flow): string | null {
+ return flow.clientBundleId ?? flow.clientPath;
+}
+
+/**
+ * The sidebar's selection as a query: hosts and paths under `under`, apps under `app`. Null when
+ * the selection holds every flow.
+ */
+export function selectionQuery(selectedIds: string[]): FlowQuery | null {
+ const under: string[] = [];
+ const app: string[] = [];
+ for (const id of selectedIds) {
+ if (id === "root" || id === "domains" || id === "apps") return null;
+ if (id.startsWith("domain:")) under.push(id.slice("domain:".length));
+ else if (id.startsWith("path:")) under.push(id.slice("path:".length));
+ else if (id.startsWith("app:")) app.push(id.slice("app:".length));
+ }
+ if (under.length === 0 && app.length === 0) return null;
+ return {
+ ...(under.length > 0 ? { under } : {}),
+ ...(app.length > 0 ? { app } : {}),
+ };
+}
+
+/**
+ * Whether `flow` is one `query` covers, as `list_flows` decides it in SQL, so new traffic joins a
+ * search as it arrives. Every term must match the URL, the method, the status or the content type.
+ */
+export function matchesQuery(flow: Flow, query: FlowQuery): boolean {
+ for (const term of (query.q ?? "").toLowerCase().split(/\s+/)) {
+ if (term === "") continue;
+ const matched =
+ flow.url.toLowerCase().includes(term) ||
+ flow.method.toLowerCase() === term ||
+ (flow.contentType?.toLowerCase().includes(term) ?? false) ||
+ matchesStatus(flow.status, term);
+ if (!matched) return false;
+ }
+ if (query.under != null && !query.under.some((node) => isUnder(flow.url, node))) return false;
+ if (query.app != null) {
+ const key = appKeyOf(flow);
+ if (key == null || !query.app.includes(key)) return false;
+ }
+ return true;
+}
+
+/** `404` exactly, `40` as the start of one, or a class such as `4xx` */
+function matchesStatus(status: number | null, term: string): boolean {
+ if (status == null) return false;
+ const klass = /^([1-9])xx$/.exec(term);
+ if (klass != null) return Math.trunc(status / 100) === Number(klass[1]);
+ if (!/^\d{1,3}$/.test(term)) return false;
+ return term.length === 3 ? status === Number(term) : String(status).startsWith(term);
+}
+
+/** The URL is the host or path `node` names, or something below it, by the sidebar's rules */
+function isUnder(url: string, node: string): boolean {
+ const path = sidebarPath(url);
+ const want = normalizeNode(node);
+ return path === want || path.startsWith(`${want}/`);
+}
+
+/**
+ * Where the sidebar places a URL: its host, lowercased and without its scheme's default port,
+ * then its path's non-empty segments, joined by `/`, as in `api.stripe.com/v1/charges`. A URL
+ * without a scheme is a host of its own. `sidebar_path` in `crates-proxy/yaak-proxy-lib`, which
+ * the database filters by, follows the same rules.
+ */
+export function sidebarPath(url: string): string {
+ const schemeEnd = url.indexOf("://");
+ if (schemeEnd === -1) return url;
+ const scheme = url.slice(0, schemeEnd).toLowerCase();
+ const rest = url.slice(schemeEnd + 3);
+ const authorityEnd = rest.search(/[/?#]/);
+ let host = (authorityEnd === -1 ? rest : rest.slice(0, authorityEnd)).toLowerCase();
+ const defaultPort =
+ scheme === "http" || scheme === "ws"
+ ? ":80"
+ : scheme === "https" || scheme === "wss"
+ ? ":443"
+ : null;
+ if (defaultPort != null && host.endsWith(defaultPort)) {
+ host = host.slice(0, -defaultPort.length);
+ }
+ const path = authorityEnd === -1 ? "" : (rest.slice(authorityEnd).split(/[?#]/)[0] ?? "");
+ const segments = path.split("/").filter((s) => s !== "");
+ return [host, ...segments].join("/");
+}
+
+/** A sidebar node as `sidebarPath` writes it, its host lowercased */
+function normalizeNode(node: string): string {
+ const slash = node.indexOf("/");
+ return slash === -1
+ ? node.toLowerCase()
+ : `${node.slice(0, slash).toLowerCase()}${node.slice(slash)}`;
+}
+
+/** The search box's text as terms for `list_flows`, or null when it has none */
+export function searchTerms(text: string): string | null {
+ const q = text.trim().replace(/\s+/g, " ");
+ return q === "" ? null : q;
+}
diff --git a/apps/yaak-proxy/lib/store.ts b/apps/yaak-proxy/lib/store.ts
index ff317dac9..90e8d25d1 100644
--- a/apps/yaak-proxy/lib/store.ts
+++ b/apps/yaak-proxy/lib/store.ts
@@ -1,6 +1,7 @@
-import type { ModelPayload } from "@yaakapp-internal/proxy-lib";
+import type { GetProxyStateResponse, ModelPayload } from "@yaakapp-internal/proxy-lib";
+import { selectedIdsFamily } from "@yaakapp-internal/ui";
import { atom, type createStore } from "jotai";
-import { followingAtom, selectedFlowAtom } from "./atoms";
+import { followingAtom, SIDEBAR_TREE_ID, selectedFlowAtom } from "./atoms";
import {
applyChanges,
emptyWindow,
@@ -12,11 +13,18 @@ import {
windowFromPage,
} from "./flowWindow";
import { listen, rpc } from "./rpc";
+import { type FlowQuery, matchesQuery, selectionQuery } from "./search";
+
+type Store = ReturnType;
/** How long new flows gather before they reach the list, so a burst renders once */
const FLUSH_MS = 100;
/** How long after new flows the total is counted again from the database */
const RECOUNT_MS = 1000;
+/** How often the database is read for new flows while another process owns the engine */
+const POLL_MS = 1000;
+/** How often the engine's owner is checked while it is another process */
+const OWNER_POLL_MS = 2000;
/** The flows in memory: a window onto the database, which keeps them all */
export const flowWindowAtom = atom(emptyWindow);
@@ -27,23 +35,73 @@ export const flowsAtom = atom((get) => get(flowWindowAtom).flows);
/** Start of the newest flow held, across all traffic */
export const newestStartedAtAtom = atom((get) => get(flowsAtom).at(-1)?.startedAt ?? null);
-/** Start and id of the oldest flow held, which loading older flows moves back */
-export const oldestFlowAtom = atom((get) => get(flowsAtom)[0] ?? null);
-
-export const hasOlderFlowsAtom = atom((get) => get(flowWindowAtom).hasOlder);
-
/** Flows in the database */
export const flowTotalAtom = atom((get) => get(flowWindowAtom).total);
+/** The first page of flows has loaded, so an empty window means an empty database */
+export const flowsLoadedAtom = atom(false);
+
+/**
+ * The database has no flows and nothing narrows the list, so the first-run card shows. A boolean
+ * of its own, so new traffic does not re-render what reads it.
+ */
+export const showFirstRunAtom = atom(
+ (get) => get(flowsLoadedAtom) && get(flowTotalAtom) === 0 && get(activeQueryAtom) == null,
+);
+
+/** Some flow held names the app that sent it, which only some platforms can tell */
+export const hasAppsAtom = atom((get) => get(flowsAtom).some((f) => f.clientName != null));
+
+/** The search box's terms, once typing settles, or null */
+export const searchTermsAtom = atom(null);
+
+/** The search the list shows, from the search box and the sidebar, or null for all traffic */
+export const activeQueryAtom = atom((get) => {
+ const q = get(searchTermsAtom);
+ const selection = selectionQuery(get(selectedIdsFamily(SIDEBAR_TREE_ID)));
+ if (q == null && selection == null) return null;
+ return { ...(q == null ? {} : { q }), ...selection };
+});
+
+/** A window onto the flows a search covers, loaded from the database as the main one is */
+export interface SearchWindow {
+ key: string;
+ query: FlowQuery;
+ win: FlowWindow;
+ /** The first page has not arrived yet */
+ loading: boolean;
+ /** Changes that came while the first page loaded, applied once it is in */
+ buffered: ModelPayload[];
+}
+
+export const searchWindowAtom = atom(null);
+
+/** The window the list shows: the search's, or all traffic's */
+const shownWindowAtom = atom((get) => get(searchWindowAtom)?.win ?? get(flowWindowAtom));
+
+/** The flows the list shows, oldest first */
+export const shownFlowsAtom = atom((get) => get(shownWindowAtom).flows);
+
+/** Flows in the database the list covers: every one, or those the search matches */
+export const shownTotalAtom = atom((get) => get(shownWindowAtom).total);
+
+/** Start and id of the oldest flow shown, which loading older flows moves back */
+export const oldestFlowAtom = atom((get) => get(shownFlowsAtom)[0] ?? null);
+
/** Follows new traffic, dropping the older flows scrolling up loaded, or holds the list */
export const followAtom = atom(
(get) => get(followingAtom),
(_get, set, following: boolean) => {
set(followingAtom, following);
- if (following) set(flowWindowAtom, (win) => trimWindow(win));
+ if (!following) return;
+ set(flowWindowAtom, (win) => trimWindow(win));
+ set(searchWindowAtom, (s) => (s == null ? s : { ...s, win: trimWindow(s.win) }));
},
);
+/** The engine's state, kept current from its events, and polled while another process has it */
+export const proxyStateAtom = atom(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
@@ -51,22 +109,29 @@ export const followAtom = atom(
*/
let changesDuringPage: ModelPayload[] | null = null;
-/** Loads the page of flows before the oldest one held */
+/** Loads the page of flows before the oldest one shown, from the search's flows when searching */
export const loadOlderFlowsAtom = atom(null, async (get, set) => {
- const { flows, hasOlder } = get(flowWindowAtom);
+ const search = get(searchWindowAtom);
+ const { flows, hasOlder } = search?.win ?? get(flowWindowAtom);
const first = flows[0];
if (changesDuringPage != null || !hasOlder || first == null) return;
const changes: ModelPayload[] = [];
changesDuringPage = changes;
try {
- const res = await rpc("list_flows", { before: first.id, limit: PAGE_SIZE });
- // Dropped when the window moved on meanwhile, trimmed or cleared.
- set(flowWindowAtom, (win) => {
+ const res = await rpc("list_flows", { ...search?.query, before: first.id, limit: PAGE_SIZE });
+ // Dropped when the window moved on meanwhile, trimmed, cleared or searched again.
+ const merge = (win: FlowWindow) => {
if (win.flows[0]?.id !== first.id) return win;
const merged = prependOlder(win, res.flows, PAGE_SIZE);
+ const kept = search == null ? changes : changesFor(search.query, changes);
// Counted when they first applied.
- return { ...applyChanges(merged, changes), total: merged.total };
- });
+ return { ...applyChanges(merged, kept), total: merged.total };
+ };
+ if (search == null) {
+ set(flowWindowAtom, merge);
+ } else {
+ set(searchWindowAtom, (s) => (s?.key === search.key ? { ...s, win: merge(s.win) } : s));
+ }
} catch (err) {
console.warn("Failed to load older flows", err);
} finally {
@@ -74,16 +139,26 @@ export const loadOlderFlowsAtom = atom(null, async (get, set) => {
}
});
+/** The changes a search's window takes: flows it matches, and every deletion */
+function changesFor(query: FlowQuery, changes: ModelPayload[]): ModelPayload[] {
+ return changes.filter((c) => c.change.type === "delete" || matchesQuery(c.model, query));
+}
+
/**
- * Loads the newest flows, then keeps them current with the engine's events. Events gather for
- * `FLUSH_MS` and apply together, and the window drops its oldest flows while the list follows,
- * so a new flow costs the same however many the database holds.
+ * Loads the newest flows, then keeps them current with the engine's events, or with reads of the
+ * database while another process owns the engine. Events gather for `FLUSH_MS` and apply
+ * together, and the window drops its oldest flows while the list follows, so a new flow costs the
+ * same however many the database holds. A search gets a window of its own, kept current the same
+ * way.
*/
-export function syncFlows(store: ReturnType) {
+export function syncFlows(store: Store) {
let pending: ModelPayload[] = [];
let timer: ReturnType | null = null;
let loaded = false;
let loads = 0;
+ let searchLoads = 0;
+ /** A flow written at or before the newest the app has seen, for reading new ones after it */
+ let seenId: string | null = null;
function flush() {
timer = null;
@@ -91,9 +166,17 @@ export function syncFlows(store: ReturnType) {
const changes = pending;
pending = [];
changesDuringPage?.push(...changes);
+ const following = store.get(followingAtom);
store.set(flowWindowAtom, (win) => {
const next = applyChanges(win, changes);
- return store.get(followingAtom) ? trimWindow(next) : next;
+ return following ? trimWindow(next) : next;
+ });
+ store.set(searchWindowAtom, (s) => {
+ if (s == null) return s;
+ // A matching flow written after the page's snapshot would otherwise be missed.
+ if (s.loading) return { ...s, buffered: [...s.buffered, ...changes] };
+ const next = applyChanges(s.win, changesFor(s.query, changes));
+ return { ...s, win: following ? trimWindow(next) : next };
});
const selected = store.get(selectedFlowAtom);
const change = changes.findLast((c) => c.model.id === selected?.id);
@@ -103,6 +186,11 @@ export function syncFlows(store: ReturnType) {
if (recountTimer == null) recountTimer = setTimeout(recount, RECOUNT_MS);
}
+ function receive(changes: ModelPayload[]) {
+ pending.push(...changes);
+ if (timer == null) timer = setTimeout(flush, FLUSH_MS);
+ }
+
// The total counts each new flow as it comes, which a flow both in a page and in the events
// around it can count twice or not at all; a count from the database a moment later settles it.
let recountTimer: ReturnType | null = null;
@@ -115,11 +203,19 @@ export function syncFlows(store: ReturnType) {
win.total === res.total ? win : { ...win, total: res.total },
);
});
+ const search = store.get(searchWindowAtom);
+ if (search == null || search.loading) return;
+ void rpc("list_flows", { ...search.query, limit: 0 }).then((res) => {
+ const total = res.matching ?? res.total;
+ store.set(searchWindowAtom, (s) =>
+ s?.key !== search.key || s.win.total === total ? s : { ...s, win: { ...s.win, total } },
+ );
+ });
}
listen("model_write", (payload) => {
- pending.push(payload);
- if (timer == null) timer = setTimeout(flush, FLUSH_MS);
+ seenId = payload.model.id;
+ receive([payload]);
});
// Loads again rather than only emptying, for a flow written between the delete and the event.
@@ -138,10 +234,123 @@ export function syncFlows(store: ReturnType) {
// A clear since asked for a newer page.
if (thisLoad !== loads) return;
store.set(flowWindowAtom, windowFromPage(res.flows, WINDOW_SIZE, res.total));
+ seenId = res.flows[0]?.id ?? null;
loaded = true;
+ store.set(flowsLoadedAtom, true);
flush();
});
+ loadSearch(store.get(activeQueryAtom));
}
+ /** Loads the first page of `query`'s flows into a window of its own, or drops it for null */
+ function loadSearch(query: FlowQuery | null) {
+ if (query == null) {
+ store.set(searchWindowAtom, null);
+ return;
+ }
+ // Unique to this load, so a slower answer to an earlier one with the same query, such as
+ // one read before a clear, is dropped.
+ const key = `${++searchLoads}:${JSON.stringify(query)}`;
+ store.set(searchWindowAtom, { key, query, win: emptyWindow, loading: true, buffered: [] });
+ const settle = (page: FlowWindow | null) =>
+ store.set(searchWindowAtom, (s) => {
+ if (s?.key !== key) return s;
+ // Applied again over the page: a flow already in it is replaced, not added.
+ const win = applyChanges(page ?? s.win, changesFor(query, s.buffered));
+ return {
+ ...s,
+ win: page == null ? win : { ...win, total: page.total },
+ loading: false,
+ buffered: [],
+ };
+ });
+ void rpc("list_flows", { ...query, limit: WINDOW_SIZE })
+ .then((res) => settle(windowFromPage(res.flows, WINDOW_SIZE, res.matching ?? res.total)))
+ .catch((err) => {
+ console.warn("Failed to search flows", err);
+ settle(null);
+ });
+ }
+
+ let activeKey = JSON.stringify(store.get(activeQueryAtom));
+ store.sub(activeQueryAtom, () => {
+ const query = store.get(activeQueryAtom);
+ const key = JSON.stringify(query);
+ if (key === activeKey) return;
+ activeKey = key;
+ loadSearch(query);
+ });
+
+ // While another process owns the engine no events come, so new flows are read from the
+ // database. Reading after a flow that started last can return a slow one already seen, which
+ // applies as an update; it never skips one.
+ let polling = false;
+ function readNewFlows() {
+ if (polling || !loaded) return;
+ polling = true;
+ const after = seenId;
+ const read =
+ after == null
+ ? rpc("list_flows", { limit: WINDOW_SIZE })
+ : rpc("list_flows", { after, limit: WINDOW_SIZE });
+ read
+ .then((res) => {
+ if (res.flows.length === 0) return;
+ // More arrived than one read holds: load the newest again rather than leave a gap.
+ if (res.flows.length >= WINDOW_SIZE) {
+ load();
+ return;
+ }
+ if (seenId === after) seenId = res.flows[0]?.id ?? seenId;
+ const known = new Set(store.get(flowsAtom).map((f) => f.id));
+ receive(
+ [...res.flows].reverse().map((model) => ({
+ model,
+ change: { type: "upsert", created: !known.has(model.id) },
+ })),
+ );
+ })
+ // The flow read after was cleared: start again from the newest.
+ .catch(() => {
+ seenId = null;
+ load();
+ })
+ .finally(() => {
+ polling = false;
+ });
+ }
+ setInterval(() => {
+ if (store.get(proxyStateAtom)?.otherOwner != null) readNewFlows();
+ }, POLL_MS);
+ // One last read once another owner lets go, for flows it wrote after the last one.
+ let hadOtherOwner = store.get(proxyStateAtom)?.otherOwner != null;
+ store.sub(proxyStateAtom, () => {
+ const hasOtherOwner = store.get(proxyStateAtom)?.otherOwner != null;
+ if (hadOtherOwner && !hasOtherOwner) readNewFlows();
+ hadOtherOwner = hasOtherOwner;
+ });
+
load();
}
+
+/**
+ * Keeps `proxyStateAtom` current: on each state change, and polled while the engine is not
+ * running here, so another process taking it or letting it go shows.
+ */
+export function syncProxyState(store: Store) {
+ function refresh() {
+ rpc("get_proxy_state", {})
+ .then((state) => store.set(proxyStateAtom, state))
+ .catch((err) => console.warn("Failed to read the proxy state", err));
+ }
+ listen("proxy_state_changed", refresh);
+ setInterval(() => {
+ if (store.get(proxyStateAtom)?.state !== "running") refresh();
+ }, OWNER_POLL_MS);
+ refresh();
+}
+
+/** Reads the proxy state again, after an action that changes it without an event */
+export async function refreshProxyState(store: Store) {
+ store.set(proxyStateAtom, await rpc("get_proxy_state", {}));
+}
diff --git a/apps/yaak-proxy/lib/systemProxy.test.ts b/apps/yaak-proxy/lib/systemProxy.test.ts
new file mode 100644
index 000000000..ab9a6e3d8
--- /dev/null
+++ b/apps/yaak-proxy/lib/systemProxy.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, test } from "vite-plus/test";
+import { recordingLabel, type SystemProxyState, systemProxySwitch } from "./systemProxy";
+
+const off: SystemProxyState = { routing: false, port: 9090, pending: null, error: null };
+
+describe("systemProxySwitch", () => {
+ test("says whether this Mac's traffic is routed now", () => {
+ expect(systemProxySwitch({ ...off, routing: true })).toEqual({
+ checked: true,
+ description: "Routing this Mac's web traffic through Yaak Proxy",
+ failed: false,
+ });
+ expect(systemProxySwitch(off)).toEqual({
+ checked: false,
+ description: "Not routing. Only apps pointed at port 9090 are captured",
+ failed: false,
+ });
+ });
+
+ test("shows the new value while the password prompt is up", () => {
+ expect(systemProxySwitch({ ...off, pending: true })).toEqual({
+ checked: true,
+ description: "Waiting for your password",
+ failed: false,
+ });
+ });
+
+ test("snaps back on a cancelled prompt and says so", () => {
+ expect(systemProxySwitch({ ...off, error: "Cancelled in the password dialog" })).toEqual({
+ checked: false,
+ description: "Cancelled in the password prompt",
+ failed: true,
+ });
+ expect(systemProxySwitch({ ...off, error: "networksetup failed" }).description).toBe(
+ "networksetup failed",
+ );
+ });
+});
+
+describe("recordingLabel", () => {
+ test("offers the opposite of the current state", () => {
+ expect(recordingLabel(true)).toBe("Pause Recording");
+ expect(recordingLabel(false)).toBe("Resume Recording");
+ });
+});
diff --git a/apps/yaak-proxy/lib/systemProxy.ts b/apps/yaak-proxy/lib/systemProxy.ts
new file mode 100644
index 000000000..031fad9b7
--- /dev/null
+++ b/apps/yaak-proxy/lib/systemProxy.ts
@@ -0,0 +1,51 @@
+/**
+ * "Override system proxy": this Mac's web traffic pointed at the running engine, changed at once
+ * whenever it is flipped, through the macOS password prompt. Whether it turns on as the app
+ * launches is a separate setting.
+ */
+export interface SystemProxyState {
+ /** This Mac's web traffic points at the engine now */
+ routing: boolean;
+ /** The port apps can be pointed at by hand */
+ port: number | null;
+ /** A change is in flight, to this value */
+ pending: boolean | null;
+ /** The last change failed, with this message */
+ error: string | null;
+}
+
+export interface SystemProxySwitch {
+ checked: boolean;
+ description: string;
+ /** The description reports a failure */
+ failed: boolean;
+}
+
+/** What the system proxy switch shows */
+export function systemProxySwitch(state: SystemProxyState): SystemProxySwitch {
+ if (state.pending != null) {
+ return { checked: state.pending, description: "Waiting for your password", failed: false };
+ }
+ // A failed change leaves the switch where the system proxy actually is.
+ const checked = state.routing;
+ if (state.error != null) {
+ const cancelled = /cancel/i.test(state.error);
+ return {
+ checked,
+ description: cancelled ? "Cancelled in the password prompt" : state.error,
+ failed: true,
+ };
+ }
+ return {
+ checked,
+ description: checked
+ ? "Routing this Mac's web traffic through Yaak Proxy"
+ : `Not routing. Only apps pointed at port ${state.port ?? 9090} are captured`,
+ failed: false,
+ };
+}
+
+/** The menu item that pauses or resumes storing flows */
+export function recordingLabel(recording: boolean): string {
+ return recording ? "Pause Recording" : "Resume Recording";
+}
diff --git a/apps/yaak-proxy/lib/toast.ts b/apps/yaak-proxy/lib/toast.ts
new file mode 100644
index 000000000..130e05ac6
--- /dev/null
+++ b/apps/yaak-proxy/lib/toast.ts
@@ -0,0 +1,12 @@
+import { type ShowToastArgs, showToast } from "@yaakapp-internal/ui";
+import { useStore } from "jotai";
+import { useCallback } from "react";
+
+/** Shows one of the client's toasts from this app's store, a success unless it says otherwise */
+export function useShowToast() {
+ const store = useStore();
+ return useCallback(
+ (toast: ShowToastArgs) => showToast(store, { color: "success", ...toast }),
+ [store],
+ );
+}
diff --git a/apps/yaak-proxy/main.tsx b/apps/yaak-proxy/main.tsx
index 246567db2..b34daffa3 100644
--- a/apps/yaak-proxy/main.tsx
+++ b/apps/yaak-proxy/main.tsx
@@ -6,12 +6,13 @@ import { type ReactNode, StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ProxyLayout } from "./components/ProxyLayout";
import { useHotkeyContextValue } from "./lib/hotkeys";
-import { syncFlows } from "./lib/store";
+import { syncFlows, syncProxyState } from "./lib/store";
import "./main.css";
const queryClient = new QueryClient();
const jotaiStore = createStore();
+syncProxyState(jotaiStore);
syncFlows(jotaiStore);
function Hotkeys({ children }: { children: ReactNode }) {
diff --git a/apps/yaak-proxy/package.json b/apps/yaak-proxy/package.json
index 3c3406794..65aa4f80b 100644
--- a/apps/yaak-proxy/package.json
+++ b/apps/yaak-proxy/package.json
@@ -12,6 +12,8 @@
"@tanstack/react-query": "^5.104.2",
"@tanstack/react-virtual": "^3.14.17",
"@tauri-apps/api": "^2.12.3",
+ "@tauri-apps/plugin-dialog": "^2.8.2",
+ "@tauri-apps/plugin-opener": "^2.7.1",
"@tauri-apps/plugin-os": "^2.4.0",
"@yaakapp-internal/proxy-lib": "^1.0.0",
"@yaakapp-internal/theme": "^1.0.0",
diff --git a/crates-proxy/yaak-proxy-lib/Cargo.toml b/crates-proxy/yaak-proxy-lib/Cargo.toml
index 6bfa3b9e6..d0bf5a144 100644
--- a/crates-proxy/yaak-proxy-lib/Cargo.toml
+++ b/crates-proxy/yaak-proxy-lib/Cargo.toml
@@ -17,7 +17,7 @@ include_dir = "0.7"
r2d2 = "0.8.10"
r2d2_sqlite = "0.32"
reqwest = { workspace = true, features = ["json"] }
-rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
+rusqlite = { version = "0.38", features = ["bundled", "chrono", "functions"] }
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
@@ -37,6 +37,27 @@ yaak-templates = { workspace = true }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+[target.'cfg(target_os = "macos")'.dependencies]
+objc2 = "0.6"
+objc2-app-kit = { version = "0.3.2", default-features = false, features = [
+ "std",
+ "objc2-core-foundation",
+ "NSBitmapImageRep",
+ "NSGraphics",
+ "NSGraphicsContext",
+ "NSImage",
+ "NSImageRep",
+ "NSWorkspace",
+] }
+objc2-foundation = { version = "0.3.2", default-features = false, features = [
+ "std",
+ "NSData",
+ "NSDictionary",
+ "NSGeometry",
+ "NSString",
+] }
+plist = "1.7"
+
[dev-dependencies]
base64 = "0.23.1"
bytes = "1"
diff --git a/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts b/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts
index 0fca90b9b..5f8202d2a 100644
--- a/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts
+++ b/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts
@@ -44,7 +44,23 @@ elapsedMs: number | null, error: string | null,
/**
* The id of the Yaak request this flow was last saved as.
*/
-savedRequestId: string | null, };
+savedRequestId: string | null,
+/**
+ * The process that opened the client connection, where the platform can tell (macOS).
+ */
+clientPid: number | null,
+/**
+ * Its app's name, or its executable's outside an app: "Google Chrome", "curl".
+ */
+clientName: string | null,
+/**
+ * Its app's bundle id.
+ */
+clientBundleId: string | null,
+/**
+ * Its app bundle's path, or its executable's outside an app.
+ */
+clientPath: string | null, };
export type FlowBody = "request" | "response";
diff --git a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts
index af32c1648..07e1a4a7f 100644
--- a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts
+++ b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts
@@ -5,6 +5,12 @@ export type ActionInvocation = { "scope": "global", action: GlobalAction, };
export type ActionMetadata = { label: string, defaultHotkey: string | null, };
+export type AppIconRequest = {
+/**
+ * A flow's `clientPath`.
+ */
+path: string, };
+
export type CaStatus = {
/**
* macOS trusts this app's CA, so HTTPS flows can be decrypted.
@@ -21,6 +27,37 @@ export type CaTrustRequest = Record;
export type ClearFlowsRequest = Record;
+/**
+ * The app behind a client connection.
+ */
+export type ClientApp = { pid: number,
+/**
+ * The app's name ("Google Chrome"), or the executable's for a process outside an app ("curl").
+ */
+name: string,
+/**
+ * The outermost `.app` bundle's CFBundleIdentifier, if any.
+ */
+bundleId: string | null,
+/**
+ * The outermost `.app` bundle's path, or the executable's path when there is no bundle.
+ */
+path: string, };
+
+export type DropReason = "hop_by_hop" | "set_by_sending" | "body_decoded";
+
+export type DroppedHeader = { name: string, value: string, reason: DropReason, };
+
+export type ExportHarRequest = {
+/**
+ * The file to write.
+ */
+path: string,
+/**
+ * These flows, or else every flow the filter covers.
+ */
+ids?: Array, filter?: FlowFilter, };
+
export type FilterBodyRequest = { flowId: string, which: FlowBody,
/**
* JSONPath, or XPath for an XML or HTML body.
@@ -39,10 +76,25 @@ export type FilterLanguage = "jsonpath" | "xpath";
* Which flows a listing or a HAR export covers. Every field narrows it; none is all of them.
*/
export type FlowFilter = {
+/**
+ * Search terms, any case, each of which must match the URL (host, path or query), the
+ * method, the status (`404`, `40`, `4xx`) or the content type: `stripe post 4xx`.
+ */
+q?: string,
/**
* Part of the host, any case: `stripe` matches `api.stripe.com`.
*/
host?: string,
+/**
+ * Only flows under one of these hosts or paths, as the app's sidebar names them:
+ * `api.stripe.com` or `api.stripe.com/v1/charges`, with any port after the host.
+ */
+under?: Array,
+/**
+ * Only flows from one of these apps, by bundle id, or by path for a process outside an
+ * app bundle.
+ */
+app?: Array,
/**
* The method, any case.
*/
@@ -68,6 +120,12 @@ before?: string, };
export type FlowsClearedPayload = Record;
+export type FolderSummary = { id: string, name: string,
+/**
+ * The folder this one is in, if any.
+ */
+folderId: string | null, };
+
export type FormatBodyRequest = { text: string, };
export type GetFlowBodyRequest = { flowId: string, which: FlowBody, };
@@ -92,9 +150,30 @@ export type GetProxyStateResponse = { state: ProxyState,
/**
* The port the engine is bound to, while it runs.
*/
-port: number | null, };
+port: number | null,
+/**
+ * The port the engine starts on.
+ */
+configuredPort: number,
+/**
+ * Flows are stored. Paused, traffic still passes through, unrecorded.
+ */
+recording: boolean,
+/**
+ * The system proxy turns on as the engine starts.
+ */
+systemProxyAtLaunch: boolean,
+/**
+ * Why the engine last failed to start, until it starts.
+ */
+startError: string | null,
+/**
+ * Another process that owns the engine, such as the CLI daemon. While one does, this app
+ * cannot start its own, and new flows reach it only through the database.
+ */
+otherOwner: OtherOwner | null, };
-export type GlobalAction = "proxy_start" | "proxy_stop" | "flows_clear";
+export type GlobalAction = "recording_toggle" | "flows_clear";
export type ListActionsRequest = Record;
@@ -108,13 +187,33 @@ flows: Array,
/**
* Flows in the database, whatever the filter.
*/
-total: number, };
+total: number,
+/**
+ * Flows the filter covers, whatever the limit, when it narrows to fewer than all of them.
+ */
+matching: number | null, };
+
+export type ListWorkspacesRequest = Record;
+
+export type ListWorkspacesResponse = {
+/**
+ * Yaak has run on this machine. Without it there is nothing to save into.
+ */
+yaakFound: boolean, workspaces: Array, };
+
+export type OtherOwner = { owner: Owner, pid: number, startedAt: string,
+/**
+ * The engine's port while it runs.
+ */
+proxyPort: number | null, };
/**
* The process that runs the engine.
*/
export type Owner = "app" | "cli";
+export type PreviewSaveRequest = { flowId: string, };
+
export type ProxySetting = { enabled: boolean, server: string, port: number, };
export type ProxyState = "running" | "stopped";
@@ -123,7 +222,49 @@ export type ProxyStatePayload = { state: ProxyState, };
export type RpcEventSchema = { flows_cleared: FlowsClearedPayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, };
-export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], };
+export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], save_flow: [SaveFlowToYaakRequest, SavedFlow], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], };
+
+export type SaveFlowToYaakRequest = { flowId: string, workspaceId: string, folderId?: string,
+/**
+ * None or blank leaves the request unnamed, and Yaak shows its URL.
+ */
+name?: string, };
+
+/**
+ * What saving a flow as a Yaak request writes, for the save dialog.
+ */
+export type SavePreview = { method: string, url: string,
+/**
+ * The URL as saved, its query split out into parameters, which Yaak shows for an unnamed
+ * request.
+ */
+requestUrl: string,
+/**
+ * The saved body type, which Yaak shows for an unnamed GraphQL request with no URL.
+ */
+bodyType: string | null,
+/**
+ * Headers Yaak sets itself or that only meant something to the proxy.
+ */
+droppedHeaders: Array,
+/**
+ * Headers saved as captured, Authorization among them.
+ */
+keptHeaders: number,
+/**
+ * The body as it is saved: "JSON", "form URL-encoded with 7 fields". None without a body.
+ */
+body: string | null,
+/**
+ * What does not carry over exactly, one sentence each.
+ */
+warnings: Array,
+/**
+ * An Authorization header is saved in plain text.
+ */
+authorization: boolean, };
+
+export type SavedFlow = { requestId: string, name: string, };
export type ServiceProxy = {
/**
@@ -131,6 +272,10 @@ export type ServiceProxy = {
*/
name: string, http: ProxySetting, https: ProxySetting, };
+export type SetRecordingRequest = { enabled: boolean, };
+
+export type SetSystemProxyAtLaunchRequest = { enabled: boolean, };
+
/**
* How a change gets administrator rights.
*/
@@ -153,3 +298,7 @@ port: number, services: Array, backend: SystemProxyBackend,
supported: boolean, };
export type SystemProxyStatusRequest = Record;
+
+export type TakeOverRequest = Record;
+
+export type WorkspaceSummary = { id: string, name: string, folders: Array, };
diff --git a/crates-proxy/yaak-proxy-lib/migrations/00000004_flow_client.sql b/crates-proxy/yaak-proxy-lib/migrations/00000004_flow_client.sql
new file mode 100644
index 000000000..2ca9dcfae
--- /dev/null
+++ b/crates-proxy/yaak-proxy-lib/migrations/00000004_flow_client.sql
@@ -0,0 +1,9 @@
+-- The app that opened the flow's client connection, where the platform can tell.
+ALTER TABLE flows ADD COLUMN client_pid INTEGER;
+ALTER TABLE flows ADD COLUMN client_name TEXT;
+ALTER TABLE flows ADD COLUMN client_bundle_id TEXT;
+ALTER TABLE flows ADD COLUMN client_path TEXT;
+
+-- Flows list and page newest first, by start and then id.
+DROP INDEX idx_flows_started_at;
+CREATE INDEX idx_flows_started_at_id ON flows (started_at, id);
diff --git a/crates-proxy/yaak-proxy-lib/src/actions.rs b/crates-proxy/yaak-proxy-lib/src/actions.rs
index e00f8a608..4aba47644 100644
--- a/crates-proxy/yaak-proxy-lib/src/actions.rs
+++ b/crates-proxy/yaak-proxy-lib/src/actions.rs
@@ -5,8 +5,8 @@ use ts_rs::TS;
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_rpc.ts")]
pub enum GlobalAction {
- ProxyStart,
- ProxyStop,
+ /// Pauses or resumes storing flows.
+ RecordingToggle,
FlowsClear,
}
@@ -33,17 +33,11 @@ fn default_hotkey(mac: &str, other: &str) -> Option {
pub fn all_global_actions() -> Vec<(ActionInvocation, ActionMetadata)> {
vec![
(
- ActionInvocation::Global { action: GlobalAction::ProxyStart },
+ ActionInvocation::Global { action: GlobalAction::RecordingToggle },
ActionMetadata {
- label: "Start Proxy".into(),
- default_hotkey: default_hotkey("Meta+Shift+P", "Ctrl+Shift+P"),
- },
- ),
- (
- ActionInvocation::Global { action: GlobalAction::ProxyStop },
- ActionMetadata {
- label: "Stop Proxy".into(),
- default_hotkey: default_hotkey("Meta+Shift+S", "Ctrl+Shift+S"),
+ label: "Pause or Resume Recording".into(),
+ // Not Cmd+Shift+R, which reloads the window in development builds.
+ default_hotkey: default_hotkey("Meta+R", "Ctrl+Shift+R"),
},
),
(
diff --git a/crates-proxy/yaak-proxy-lib/src/attribution.rs b/crates-proxy/yaak-proxy-lib/src/attribution.rs
new file mode 100644
index 000000000..8e8ae4b3d
--- /dev/null
+++ b/crates-proxy/yaak-proxy-lib/src/attribution.rs
@@ -0,0 +1,1051 @@
+//! Which app opened each client connection. The client's end of a connection to the engine is a
+//! loopback TCP socket whose local port is the client's ephemeral port and whose foreign port is
+//! the engine's, so the app behind it is the process holding that socket. On macOS one worker
+//! thread finds it through `libproc`, once per connection, checking the processes that opened
+//! recent connections before scanning them all. Other platforms report nothing.
+
+use serde::{Deserialize, Serialize};
+use std::net::SocketAddr;
+use std::time::Duration;
+use ts_rs::TS;
+
+/// The app behind a client connection.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[serde(rename_all = "camelCase")]
+#[ts(export, export_to = "gen_rpc.ts")]
+pub struct ClientApp {
+ pub pid: i32,
+ /// The app's name ("Google Chrome"), or the executable's for a process outside an app ("curl").
+ pub name: String,
+ /// The outermost `.app` bundle's CFBundleIdentifier, if any.
+ pub bundle_id: Option,
+ /// The outermost `.app` bundle's path, or the executable's path when there is no bundle.
+ pub path: String,
+}
+
+/// Finds the app behind each client connection, once per connection, off the caller's thread.
+/// Cheap to clone.
+#[derive(Clone)]
+pub struct Attribution {
+ lookups: Option,
+}
+
+impl Attribution {
+ /// For an engine listening on `proxy_port`. On platforms without a lookup this does nothing
+ /// and spawns nothing.
+ pub fn new(proxy_port: u16) -> Self {
+ Self { lookups: imp::Lookups::start(proxy_port) }
+ }
+
+ /// Starts finding the app behind `connection`, whose client end is `client_addr`. Called on
+ /// each request; only the first call per connection does anything. Returns immediately.
+ pub fn begin(&self, connection: u64, client_addr: SocketAddr) {
+ if let Some(lookups) = &self.lookups {
+ lookups.begin(connection, client_addr);
+ }
+ }
+
+ /// The app behind `connection`, waiting up to `wait` for a lookup still in progress. None
+ /// when unknown, not found, or not supported.
+ pub fn get(&self, connection: u64, wait: Duration) -> Option {
+ self.lookups.as_ref().and_then(|l| l.get(connection, wait))
+ }
+}
+
+/// The icon of the app or executable at `path` (a ClientApp's `path`) as a PNG, 64 by 64
+/// pixels, cached in memory by path. `Ok(None)` on platforms without icons. Blocks.
+#[cfg(target_os = "macos")]
+pub fn app_icon_png(path: &str) -> Result