Proxy GUI pass (#793)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gregory SchierandClaude Opus 5.5 authored and GitHub committed 2026-10-28 22:27:18 -07:00
1 parent 96ab0a8a60
commit e82ffc53ad
73 files changed
+6003 -1257

No files matched your search

Generated
+6
View File
@@ -11375,6 +11375,8 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-os", "tauri-plugin-os",
"yaak-mac-window", "yaak-mac-window",
"yaak-proxy-lib", "yaak-proxy-lib",
@@ -11765,6 +11767,10 @@ dependencies = [
"include_dir", "include_dir",
"libc", "libc",
"log 0.4.34", "log 0.4.34",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-foundation 0.3.2",
"plist",
"r2d2", "r2d2",
"r2d2_sqlite", "r2d2_sqlite",
"reqwest", "reqwest",
@@ -12,7 +12,7 @@ export function ErrorBoundary({ name, children }: { name: string; children: Reac
); );
} }
function showErrorDialog(error: unknown) { export function showErrorDialog(error: unknown) {
showDialog({ id: "error-boundary", render: () => <RouteError error={error} /> }); showDialog({ id: "error-boundary", render: () => <RouteError error={error} /> });
} }
+4 -41
View File
@@ -1,43 +1,6 @@
import { useAtomValue } from "jotai"; import { Toasts as BaseToasts } from "@yaakapp-internal/ui";
import { AnimatePresence } from "motion/react"; import { showErrorDialog } from "./ErrorBoundary";
import type { ReactNode } from "react";
import { hideToast, toastsAtom } from "../lib/toast";
import { Toast, type ToastProps } from "./core/Toast";
import { ErrorBoundary } from "./ErrorBoundary";
import { Portal } from "@yaakapp-internal/ui";
export type ToastInstance = { export type { ToastInstance } from "@yaakapp-internal/ui";
id: string;
uniqueKey: string;
message: ReactNode;
timeout: 3000 | 5000 | 8000 | (number & {}) | null;
onClose?: ToastProps["onClose"];
} & Omit<ToastProps, "onClose" | "open" | "children" | "timeout">;
export const Toasts = () => { export const Toasts = () => <BaseToasts onShowError={showErrorDialog} />;
const toasts = useAtomValue(toastsAtom);
return (
<Portal name="toasts">
<div className="absolute right-0 bottom-0 z-50">
<AnimatePresence>
{toasts.map((toast: ToastInstance) => {
const { message, uniqueKey, ...props } = toast;
return (
<ErrorBoundary key={uniqueKey} name={`Toast ${uniqueKey}`}>
<Toast
open
{...props}
// We call onClose inside actions.hide instead of passing to toast so that
// it gets called from external close calls as well
onClose={() => hideToast(toast)}
>
{message}
</Toast>
</ErrorBoundary>
);
})}
</AnimatePresence>
</div>
</Portal>
);
};
+2 -72
View File
@@ -1,72 +1,2 @@
import { HStack, Icon, IconTooltip } from "@yaakapp-internal/ui"; export type { CheckboxProps } from "@yaakapp-internal/ui";
import classNames from "classnames"; export { Checkbox } from "@yaakapp-internal/ui";
import type { ReactNode } from "react";
export interface CheckboxProps {
checked: boolean | "indeterminate";
title: ReactNode;
onChange: (checked: boolean) => void;
className?: string;
disabled?: boolean;
inputWrapperClassName?: string;
hideLabel?: boolean;
fullWidth?: boolean;
help?: ReactNode;
size?: "sm" | "md";
}
export function Checkbox({
checked,
onChange,
className,
inputWrapperClassName,
disabled,
title,
hideLabel,
fullWidth,
help,
size = "sm",
}: CheckboxProps) {
return (
<HStack
as="label"
alignItems="center"
space={2}
className={classNames(className, "text-text mr-auto")}
>
<div className={classNames(inputWrapperClassName, "x-theme-input", "relative flex mr-0.5")}>
<input
aria-hidden
className={classNames(
"appearance-none shrink-0 border border-border",
size === "sm" && "w-4 h-4",
size === "md" && "w-5 h-5",
"rounded-sm outline-hidden ring-0",
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
disabled && "border-dotted",
)}
type="checkbox"
disabled={disabled}
onChange={() => {
onChange(checked === "indeterminate" ? true : !checked);
}}
/>
<div className="absolute inset-0 flex items-center justify-center">
<Icon
size={size}
className={classNames(disabled && "opacity-disabled")}
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
/>
</div>
</div>
{!hideLabel && (
<div
className={classNames("text-sm", fullWidth && "w-full", disabled && "opacity-disabled")}
>
{title}
</div>
)}
{help && <IconTooltip content={help} />}
</HStack>
);
}
+2 -200
View File
@@ -1,200 +1,2 @@
import type { DialogSize } from "@yaakapp-internal/plugins"; export type { DialogFooterAction, DialogProps } from "@yaakapp-internal/ui";
import { Heading, HStack, Overlay } from "@yaakapp-internal/ui"; export { Dialog, DialogFooter } 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<HTMLDivElement | null>(null);
const descriptionId = useMemo(
() => (description ? Math.random().toString(36).slice(2) : undefined),
[description],
);
return (
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
<div
role="dialog"
className={classNames(
"py-4 x-theme-dialog absolute inset-0 pointer-events-none",
"h-full flex flex-col items-center justify-center",
vAlign === "top" && "justify-start",
vAlign === "center" && "justify-center",
)}
aria-labelledby={titleId}
aria-describedby={descriptionId}
tabIndex={-1}
onKeyDown={(e) => {
// 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();
}
}}
>
<m.div
initial={{ top: 5, scale: 0.97 }}
animate={{ top: 0, scale: 1 }}
className={classNames(
className,
"grid grid-rows-[auto_auto_minmax(0,1fr)_auto]",
"grid-cols-1", // must be here for inline code blocks to correctly break words
"relative bg-surface pointer-events-auto",
"rounded-lg",
"border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]",
"min-h-40",
"max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]",
size === "sm" && "w-120",
size === "md" && "w-200",
size === "lg" && "w-280",
size === "full" && "w-screen h-screen",
size === "dynamic" && "min-w-80 max-w-[100vw]",
)}
>
{title ? (
<Heading className="px-6 mt-4 mb-2" level={1} id={titleId}>
{title}
</Heading>
) : (
<span />
)}
{description ? (
<div className="min-h-0 px-6 text-text-subtle mb-3" id={descriptionId}>
{description}
</div>
) : (
<span />
)}
<div
className={classNames(
"h-full w-full grid grid-cols-[minmax(0,1fr)] grid-rows-1",
!noPadding && "px-6 py-2",
!noScroll && "overflow-y-auto overflow-x-hidden",
)}
>
<DialogFooterSlot.Provider value={footerEl}>{children}</DialogFooterSlot.Provider>
</div>
<div ref={setFooterEl} className="contents" />
{/*Put close at the end so that it's the last thing to be tabbed to*/}
{!disableClose && !hideX && (
<div className="ml-auto absolute right-1 top-1">
<IconButton
className="opacity-70 hover:opacity-100"
onClick={onClose}
title="Close dialog (Esc)"
aria-label="Close"
size="sm"
icon="x"
/>
</div>
)}
</m.div>
</div>
</Overlay>
);
}
const DialogFooterSlot = createContext<HTMLDivElement | null>(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 = (
<footer
className={classNames(
"px-6 flex items-center gap-3",
inline ? "pt-1 pb-4" : "py-3 border-t border-border-subtle",
)}
>
{leftSlot != null && <div className="mr-auto min-w-0">{leftSlot}</div>}
<HStack space={2} justifyContent="end" className="ml-auto shrink-0">
{actions.map((action, i) => (
<Button
// Labels change with state ("Cloning...", "Apply 3 Changes"), so they can't be keys
key={i}
type={action.form != null ? "submit" : "button"}
form={action.form}
color={action.color ?? "secondary"}
variant={action.variant ?? (action.color == null ? "border" : "solid")}
disabled={action.disabled}
isLoading={action.isLoading}
autoFocus={action.autoFocus}
leftSlot={action.leftSlot}
rightSlot={action.rightSlot}
onClick={action.onClick}
>
{action.label}
</Button>
))}
</HStack>
</footer>
);
return slot == null ? footer : createPortal(footer, slot);
}
+2 -242
View File
@@ -1,242 +1,2 @@
import { HStack, useStateWithDeps, Label } from "@yaakapp-internal/ui"; export type { PlainInputProps } from "@yaakapp-internal/ui";
import classNames from "classnames"; export { PlainInput } from "@yaakapp-internal/ui";
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<InputHTMLAttributes<HTMLInputElement>, "inputMode" | "onKeyDownCapture" | "step"> & {
onFocusRaw?: InputHTMLAttributes<HTMLInputElement>["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<boolean>(false, [forceUpdateKey]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle<{ focus: () => void } | null, { focus: () => void } | null>(
ref,
() => inputRef.current,
);
const handleFocus = useCallback(
(e: FocusEvent<HTMLInputElement>) => {
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<HTMLDivElement>(null);
return (
<div
ref={wrapperRef}
className={classNames(
"w-full",
"pointer-events-auto", // Just in case we're placing in disabled parent
labelPosition === "left" && "flex items-center gap-2",
labelPosition === "top" && "flex-row gap-0.5",
)}
>
<Label
htmlFor={id.current}
className={labelClassName}
visuallyHidden={hideLabel}
required={required}
help={help}
rightSlot={labelRightSlot}
>
{label}
</Label>
<HStack
alignItems="stretch"
className={classNames(
containerClassName,
"x-theme-input",
"relative w-full rounded-md text",
"border",
"overflow-hidden",
focused && !disabled ? "border-border-focus" : "border-border-subtle",
disabled && "border-dotted",
hasChanged && "has-invalid:border-danger", // For built-in HTML validation
size === "md" && "min-h-md",
size === "sm" && "min-h-sm",
size === "xs" && "min-h-xs",
size === "2xs" && "min-h-2xs",
)}
>
{tint != null && (
<div
aria-hidden
className={classNames(
"absolute inset-0 opacity-5 pointer-events-none",
tint === "info" && "bg-info",
tint === "warning" && "bg-warning",
)}
/>
)}
{leftSlot}
<HStack
className={classNames(
"w-full min-w-0",
leftSlot ? "pl-0.5 -ml-2" : null,
rightSlot ? "pr-0.5 -mr-2" : null,
)}
>
<input
id={id.current}
ref={inputRef}
key={forceUpdateKey}
type={type === "password" && !obscured ? "text" : type}
name={name}
// oxlint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
defaultValue={defaultValue ?? undefined}
disabled={disabled}
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
writingsuggestions="false"
inputMode={inputMode}
onChange={(e) => handleChange(e.target.value)}
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
className={classNames(commonClassName, "h-full disabled:opacity-disabled")}
onFocus={handleFocus}
onBlur={handleBlur}
required={required}
step={step}
placeholder={placeholder}
onKeyDownCapture={onKeyDownCapture}
/>
</HStack>
{type === "password" && !hideObscureToggle && (
<IconButton
title={
obscured
? `Show ${typeof label === "string" ? label : "field"}`
: `Obscure ${typeof label === "string" ? label : "field"}`
}
size="xs"
className="mr-0.5 group/obscure h-auto! my-0.5"
iconClassName="group-hover/obscure:text"
iconSize="sm"
icon={obscured ? "eye" : "eye_closed"}
onClick={() => setObscured((o) => !o)}
/>
)}
{rightSlot}
</HStack>
</div>
);
});
function validateRequire(v: string) {
return v.length > 0;
}
+2 -148
View File
@@ -1,148 +1,2 @@
import type { ShowToastRequest } from "@yaakapp-internal/plugins"; export type { ToastProps } from "@yaakapp-internal/ui";
import { Icon, type IconProps, VStack } from "@yaakapp-internal/ui"; export { Toast } from "@yaakapp-internal/ui";
import classNames from "classnames";
import * as m from "motion/react-m";
import type { ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useKey } from "react-use";
import { IconButton } from "./IconButton";
export interface ToastProps {
children: ReactNode;
open: boolean;
onClose: () => void;
className?: string;
timeout: number | null;
action?: (args: { hide: () => void }) => ReactNode;
icon?: ShowToastRequest["icon"] | null;
color?: ShowToastRequest["color"];
// Grow with the content (up to the viewport) instead of scrolling internally
// past the default max height
dynamicHeight?: boolean;
// Hide the close button, for toasts that render their own dismiss action.
// Escape still closes the toast
hideDismiss?: boolean;
}
const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon"] | null> = {
custom: null,
danger: "alert_triangle",
info: "info",
notice: "alert_triangle",
primary: "info",
secondary: "info",
success: "check_circle",
warning: "alert_triangle",
};
export function Toast({
children,
open,
onClose,
timeout,
action,
icon,
color,
dynamicHeight,
hideDismiss,
}: ToastProps) {
const onCloseRef = useRef(onClose);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [autoHideCanceled, setAutoHideCanceled] = useState(false);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
const cancelAutoHide = useCallback(() => {
if (timeoutRef.current == null) return;
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
setAutoHideCanceled(true);
}, []);
useEffect(() => {
if (!open || timeout == null || autoHideCanceled) return;
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
onCloseRef.current();
}, timeout);
return () => {
if (timeoutRef.current == null) return;
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
};
}, [autoHideCanceled, open, timeout]);
useKey(
"Escape",
() => {
if (!open) return;
onClose();
},
{},
[open],
);
const toastIcon = icon === null ? null : (icon ?? (color && color in ICONS && ICONS[color]));
return (
<m.div
initial={{ opacity: 0, right: "-10%" }}
animate={{ opacity: 100, right: 0 }}
exit={{ opacity: 0, right: "-100%" }}
transition={{ duration: 0.2 }}
className={classNames("bg-surface m-2 rounded-lg")}
>
<div
className={classNames(
`x-theme-toast x-theme-toast--${color}`,
"pointer-events-auto overflow-hidden",
"relative pointer-events-auto bg-surface text-text rounded-lg",
"border border-border shadow-lg w-100",
)}
onFocusCapture={cancelAutoHide}
onKeyDownCapture={cancelAutoHide}
onPointerDownCapture={cancelAutoHide}
>
<div
className={classNames(
"pl-3 py-3 flex items-start gap-2 w-full overflow-auto",
hideDismiss ? "pr-3" : "pr-10",
dynamicHeight ? "max-h-[80vh]" : "max-h-44",
)}
>
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />}
<VStack space={2} className="w-full min-w-0">
<div className="select-auto">{children}</div>
{action?.({ hide: onClose })}
</VStack>
</div>
{!hideDismiss && (
<IconButton
color={color}
variant="border"
className="opacity-60 border-0 absolute! top-2 right-2"
title="Dismiss"
icon="x"
onClick={onClose}
/>
)}
{timeout != null && !autoHideCanceled && (
<div className="w-full absolute bottom-0 left-0 right-0">
<m.div
className="bg-surface-highlight h-[3px]"
initial={{ width: "100%" }}
animate={{ width: "0%", opacity: 0.2 }}
transition={{ duration: timeout / 1000, ease: "linear" }}
/>
</div>
)}
</div>
</m.div>
);
}
+1 -8
View File
@@ -1,8 +1 @@
import { useCallback, useState } from "react"; export { useRandomKey } from "@yaakapp-internal/ui";
import { generateId } from "@yaakapp-internal/lib/generateId";
export function useRandomKey(initialValue?: string) {
const [value, setValue] = useState<string>(initialValue ?? generateId());
const regenerate = useCallback(() => setValue(generateId()), []);
return [value, regenerate] as const;
}
+2 -36
View File
@@ -1,43 +1,9 @@
import type { AnyModel } from "@yaakapp-internal/models"; import type { AnyModel } from "@yaakapp-internal/models";
import { foldersAtom } from "@yaakapp-internal/models"; import { foldersAtom } from "@yaakapp-internal/models";
import { resolvedModelName } from "@yaakapp-internal/ui/resolvedModelName";
import { jotaiStore } from "./jotai"; import { jotaiStore } from "./jotai";
import { replaceTemplateTags } from "./templateTags";
export function resolvedModelName(r: AnyModel | null): string { export { resolvedModelName };
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 function resolvedModelNameWithFolders(model: AnyModel | null): string { export function resolvedModelNameWithFolders(model: AnyModel | null): string {
return resolvedModelNameWithFoldersArray(model).join(" / "); return resolvedModelNameWithFoldersArray(model).join(" / ");
+2 -137
View File
@@ -1,137 +1,2 @@
/* export type { TemplateTagMatch } from "@yaakapp-internal/ui/templateTags";
* Finding template tags in text. export { findTemplateTags, replaceTemplateTags } from "@yaakapp-internal/ui/templateTags";
*
* 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<number> | 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);
}
+13 -38
View File
@@ -1,50 +1,25 @@
import { atom } from "jotai"; // The toast store alone, without the components, which tests in a plain runtime can't load
import type { ToastInstance } from "../components/Toasts"; import {
import { generateId } from "@yaakapp-internal/lib/generateId"; hideToast as hideToastIn,
hideToastById as hideToastByIdIn,
type ShowToastArgs,
showToast as showToastIn,
type ToastInstance,
} from "@yaakapp-internal/ui/toast";
import { jotaiStore } from "./jotai"; import { jotaiStore } from "./jotai";
export const toastsAtom = atom<ToastInstance[]>([]); export { toastsAtom } from "@yaakapp-internal/ui/toast";
export function showToast({ export function showToast(args: ShowToastArgs) {
id, return showToastIn(jotaiStore, args);
timeout = 5000,
...props
}: Omit<ToastInstance, "id" | "timeout" | "uniqueKey"> & {
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 hideToastById(id: string) { export function hideToastById(id: string) {
const toast = jotaiStore.get(toastsAtom).find((t) => t.id === id); hideToastByIdIn(jotaiStore, id);
if (toast) hideToast(toast);
} }
export function hideToast(toHide: ToastInstance) { export function hideToast(toHide: ToastInstance) {
jotaiStore.set(toastsAtom, (all) => { hideToastIn(jotaiStore, toHide);
const t = all.find((t) => t.uniqueKey === toHide.uniqueKey);
t?.onClose?.();
return all.filter((t) => t.uniqueKey !== toHide.uniqueKey);
});
} }
export function showErrorToast<T>({ export function showErrorToast<T>({
+49
View File
@@ -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<string, string | null>();
const loading = new Map<string, Promise<string | null>>();
function loadIcon(path: string): Promise<string | null> {
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 ? (
<span aria-hidden className={classNames(className, "inline-block w-4 h-4 shrink-0")} />
) : (
<img alt="" src={url} className={classNames(className, "w-4 h-4 shrink-0")} />
);
}
+225
View File
@@ -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 (
<div className="grid place-items-center h-full px-10 pb-16 pt-6">
<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.
</div>
<div className="rounded-lg border border-border-subtle bg-surface divide-y divide-border-subtle">
<Step
n={1}
state={trustState}
title={trusted ? "Certificate trusted" : "Trust the certificate"}
description={
trusted
? "Yaak Proxy CA, in your login keychain."
: trustState === "wait"
? "Waiting for your password in the macOS prompt."
: trustState === "fail"
? (trust.error?.message ?? "The certificate was not trusted.")
: "So HTTPS reads as plain text. macOS asks for your login password once."
}
action={
trustState === "next" || trustState === "fail" ? (
<Button
size="2xs"
variant={trustState === "fail" ? "border" : "solid"}
color={trustState === "fail" ? "default" : "primary"}
onClick={() => trust.mutate({})}
>
{trustState === "fail" ? "Try again" : "Trust certificate"}
</Button>
) : null
}
/>
{systemSupported && (
<Step
n={2}
state={systemState}
title={systemOn ? "System proxy on" : "Turn on the system proxy"}
description={
otherOwner && !systemOn
? proxyState?.otherOwner?.owner === "cli"
? "The CLI owns the proxy. Take over to turn this on from here."
: "Another Yaak Proxy window owns the engine. Quit it to use this one."
: systemSwitch.description
}
action={
(systemState === "next" || systemState === "fail") && running ? (
<Button
size="2xs"
variant={systemState === "fail" ? "border" : "solid"}
color={systemState === "fail" ? "default" : "primary"}
onClick={() => setSystemProxy.mutate({ enabled: true })}
>
{systemState === "fail" ? "Try again" : "Turn on"}
</Button>
) : null
}
/>
)}
<Step
n={systemSupported ? 3 : 2}
state={trafficState}
title={trafficState === "wait" ? "Waiting for traffic" : "See traffic"}
description={
trafficState === "fail"
? (retry.error?.message ?? proxyState?.startError ?? "The proxy has not started.")
: !recording
? "Recording is paused, so traffic passes through without being stored."
: trafficState === "wait"
? "Open a website or send a request, and its flow appears here."
: "Flows appear here as apps send requests."
}
action={
trafficState === "fail" ? (
<Button
size="2xs"
variant="border"
isLoading={retry.isPending}
onClick={() => retry.mutate({})}
>
Retry
</Button>
) : !recording && running ? (
<Button
size="2xs"
color="primary"
onClick={() => execute.mutate({ scope: "global", action: "recording_toggle" })}
>
Resume recording
</Button>
) : null
}
/>
</div>
<div className="flex items-center gap-3 mt-4 text-sm text-text-subtle">
<span>Only need one terminal or one app? Copy the environment instead.</span>
<Button
size="2xs"
variant="border"
className="ml-auto shrink-0"
leftSlot={<Icon icon="square_terminal" size="sm" />}
onClick={() => void copyEnvironment()}
>
Copy environment
</Button>
</div>
</div>
</div>
);
}
function Step({
n,
state,
title,
description,
action,
}: {
n: number;
state: StepState;
title: string;
description: ReactNode;
action: ReactNode;
}) {
return (
<div className="grid grid-cols-[1.4rem_minmax(0,1fr)_auto] gap-x-3 items-center px-4 py-3">
<StepMark n={n} state={state} />
<div className="min-w-0">
<div className={classNames("text-sm", state === "todo" ? "text-text-subtle" : "text-text")}>
{title}
</div>
<div
className={classNames(
"text-xs mt-0.5",
state === "fail" ? "text-danger" : "text-text-subtle",
)}
>
{description}
</div>
</div>
<div>{action}</div>
</div>
);
}
function StepMark({ n, state }: { n: number; state: StepState }) {
const base = "w-[1.4rem] h-[1.4rem] rounded-full grid place-items-center text-xs";
switch (state) {
case "done":
return (
<span className={classNames(base, "bg-success/15 text-success")}>
<Icon icon="check" size="sm" />
</span>
);
case "fail":
return (
<span className={classNames(base, "bg-danger/15 text-danger")}>
<Icon icon="x" size="sm" />
</span>
);
case "wait":
return (
<span className={classNames(base, "text-text-subtle")}>
<LoadingIcon size="sm" />
</span>
);
case "next":
return <span className={classNames(base, "bg-primary text-white font-semibold")}>{n}</span>;
case "todo":
return <span className={classNames(base, "border border-border text-text-subtle")}>{n}</span>;
}
}
+23 -6
View File
@@ -10,6 +10,7 @@ import {
HStack, HStack,
HttpStatusTagRaw, HttpStatusTagRaw,
Icon, Icon,
IconButton,
SizeTag, SizeTag,
type TabButtonRenderProps, type TabButtonRenderProps,
type TabItem, type TabItem,
@@ -17,11 +18,12 @@ import {
Tabs, Tabs,
} from "@yaakapp-internal/ui"; } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useAtomValue } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { selectedFlowAtom } from "../lib/atoms"; import { selectedFlowAtom } from "../lib/atoms";
import { findHeader, mimeEssence } from "../lib/body"; import { findHeader, mimeEssence } from "../lib/body";
import { saveDialogFlowAtom, useFlowActions } from "../lib/flowActions";
import { statusReason } from "../lib/statusReason"; import { statusReason } from "../lib/statusReason";
import { EmptyState, FlowBodyViewer, useFlowBody } from "./FlowBodyViewer"; import { EmptyState, FlowBodyViewer, useFlowBody } from "./FlowBodyViewer";
import { FlowHeaders, FlowQuery, queryParams } from "./FlowHeaders"; import { FlowHeaders, FlowQuery, queryParams } from "./FlowHeaders";
@@ -50,10 +52,8 @@ export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideB
<div className="grid grid-cols-[auto_auto] gap-x-6 gap-y-2 text-left"> <div className="grid grid-cols-[auto_auto] gap-x-6 gap-y-2 text-left">
<span>Select a flow</span> <span>Select a flow</span>
<Keys keys={["↑", "↓"]} /> <Keys keys={["↑", "↓"]} />
<span>Start the proxy</span> <span>Pause or resume recording</span>
<Keys keys={["⌘", "⇧", "P"]} /> <Keys keys={["⌘", "R"]} />
<span>Stop the proxy</span>
<Keys keys={["⌘", "⇧", "S"]} />
<span>Clear flows</span> <span>Clear flows</span>
<Keys keys={["⌘", "K"]} /> <Keys keys={["⌘", "K"]} />
</div> </div>
@@ -73,6 +73,8 @@ function FlowPane({ flow }: { flow: Flow }) {
const noResponse = flow.error != null && flow.status == null; const noResponse = flow.error != null && flow.status == null;
const mime = mimeEssence(findHeader(flow.responseHeaders, "content-type")); const mime = mimeEssence(findHeader(flow.responseHeaders, "content-type"));
const queryCount = useMemo(() => queryParams(flow.url).length, [flow.url]); const queryCount = useMemo(() => queryParams(flow.url).length, [flow.url]);
const openSave = useSetAtom(saveDialogFlowAtom);
const flowActions = useFlowActions();
const tabs = useMemo<TabItem[]>( const tabs = useMemo<TabItem[]>(
() => [ () => [
@@ -125,7 +127,7 @@ function FlowPane({ flow }: { flow: Flow }) {
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1"> <div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
<div <div
className={classNames( className={classNames(
"grid grid-cols-[auto_minmax(4rem,1fr)] items-center h-sm -mb-1.5", "grid grid-cols-[auto_minmax(4rem,1fr)_auto] items-center h-sm -mb-1.5",
"cursor-default select-none whitespace-nowrap w-full px-3", "cursor-default select-none whitespace-nowrap w-full px-3",
"overflow-x-auto font-mono text-sm text-text-subtle hide-scrollbars", "overflow-x-auto font-mono text-sm text-text-subtle hide-scrollbars",
)} )}
@@ -172,6 +174,21 @@ function FlowPane({ flow }: { flow: Flow }) {
) : ( ) : (
<span /> <span />
)} )}
<HStack space={1} className="font-sans ml-3">
<Button
size="2xs"
variant="border"
color={flow.savedRequestId == null ? "default" : "primary"}
title={flow.savedRequestId == null ? undefined : "Saved to Yaak. Click to save again."}
leftSlot={<Icon icon="bookmark" size="xs" />}
onClick={() => openSave(flow)}
>
{flow.savedRequestId == null ? "Save to Yaak" : "Saved"}
</Button>
<Dropdown items={flowActions(flow).slice(2)}>
<IconButton size="xs" icon="ellipsis" iconColor="secondary" title="Copy and export" />
</Dropdown>
</HStack>
</div> </div>
<div className="overflow-hidden flex flex-col min-h-0"> <div className="overflow-hidden flex flex-col min-h-0">
+86 -11
View File
@@ -1,17 +1,34 @@
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { formatSize } from "@yaakapp-internal/lib/formatSize"; import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow } from "@yaakapp-internal/proxy-lib"; import type { Flow } from "@yaakapp-internal/proxy-lib";
import { formatMillis, HttpMethodTagRaw, HttpStatusTagRaw, Icon } from "@yaakapp-internal/ui"; import {
ContextMenu,
formatMillis,
HttpMethodTagRaw,
HttpStatusTagRaw,
Icon,
} from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import type { CSSProperties, KeyboardEvent, WheelEvent } from "react"; import type { CSSProperties, KeyboardEvent, MouseEvent, WheelEvent } from "react";
import { memo, useCallback, useLayoutEffect, useRef, useState } from "react"; import { memo, useCallback, useLayoutEffect, useRef, useState } from "react";
import { selectedFlowAtom, selectedFlowIdAtom } from "../lib/atoms"; import { selectedFlowAtom, selectedFlowIdAtom } from "../lib/atoms";
import { shortContentType } from "../lib/body"; import { shortContentType } from "../lib/body";
import { useFlowActions } from "../lib/flowActions";
import { compareFlows } from "../lib/flowWindow"; import { compareFlows } from "../lib/flowWindow";
import { countNewFlows } from "../lib/follow"; import { countNewFlows } from "../lib/follow";
import { followAtom, loadOlderFlowsAtom, newestStartedAtAtom, oldestFlowAtom } from "../lib/store"; import {
import { filteredExchangesAtom } from "./Sidebar"; activeQueryAtom,
followAtom,
hasAppsAtom,
loadOlderFlowsAtom,
newestStartedAtAtom,
oldestFlowAtom,
searchWindowAtom,
shownFlowsAtom,
} from "../lib/store";
import { AppIcon } from "./AppIcon";
import { SearchBar } from "./SearchBar";
const ROW_HEIGHT = 24; const ROW_HEIGHT = 24;
const BOTTOM_PADDING = 6; const BOTTOM_PADDING = 6;
@@ -29,7 +46,10 @@ interface Props {
* the top loads older flows from the database. * the top loads older flows from the database.
*/ */
export function FlowList({ sideBySide, style, className }: Props) { export function FlowList({ sideBySide, style, className }: Props) {
const flows = useAtomValue(filteredExchangesAtom); const flows = useAtomValue(shownFlowsAtom);
const hasApps = useAtomValue(hasAppsAtom);
const [menu, setMenu] = useState<{ flow: Flow; x: number; y: number } | null>(null);
const flowActions = useFlowActions();
const selectedId = useAtomValue(selectedFlowIdAtom); const selectedId = useAtomValue(selectedFlowIdAtom);
const setSelectedFlow = useSetAtom(selectedFlowAtom); const setSelectedFlow = useSetAtom(selectedFlowAtom);
const loadOlder = useSetAtom(loadOlderFlowsAtom); const loadOlder = useSetAtom(loadOlderFlowsAtom);
@@ -153,17 +173,33 @@ export function FlowList({ sideBySide, style, className }: Props) {
[flows, loadOlderHeld, select, selectedId], [flows, loadOlderHeld, select, selectedId],
); );
const openMenu = useCallback(
(index: number, e: MouseEvent) => {
const flow = latest.current.flows[index];
if (flow == null) return;
e.preventDefault();
select(index);
setMenu({ flow, x: e.clientX, y: e.clientY });
},
[select],
);
const newCount = following ? 0 : countNewFlows(flows, heldAt); const newCount = following ? 0 : countNewFlows(flows, heldAt);
const columns = sideBySide const columns = sideBySide
? "grid-cols-[0.9rem_2.6rem_minmax(0,2fr)_minmax(0,3fr)_2.4rem_3.6rem]" ? hasApps
: "grid-cols-[0.9rem_2.6rem_13rem_minmax(0,1fr)_2.4rem_5rem_4.5rem_3.8rem]"; ? "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]";
return ( return (
<div <div
style={style} style={style}
className={classNames(className, "grid grid-rows-[auto_minmax(0,1fr)] min-h-0 text-sm")} className={classNames(className, "grid grid-rows-[auto_auto_minmax(0,1fr)] min-h-0 text-sm")}
> >
<SearchBar />
<div <div
className={classNames( className={classNames(
columns, columns,
@@ -172,6 +208,7 @@ export function FlowList({ sideBySide, style, className }: Props) {
)} )}
> >
<span /> <span />
{hasApps && <span>{sideBySide ? "" : "App"}</span>}
<span>Method</span> <span>Method</span>
<span>Host</span> <span>Host</span>
<span>Path</span> <span>Path</span>
@@ -192,7 +229,7 @@ export function FlowList({ sideBySide, style, className }: Props) {
className="group/flows h-full overflow-y-auto px-1.5 outline-hidden" className="group/flows h-full overflow-y-auto px-1.5 outline-hidden"
> >
{flows.length === 0 ? ( {flows.length === 0 ? (
<p className="text-text-subtlest text-sm px-3 py-3">No traffic yet</p> <EmptyList />
) : ( ) : (
<div className="relative" style={{ height: virtualizer.getTotalSize() }}> <div className="relative" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((item) => { {virtualizer.getVirtualItems().map((item) => {
@@ -206,8 +243,10 @@ export function FlowList({ sideBySide, style, className }: Props) {
start={item.start} start={item.start}
columns={columns} columns={columns}
sideBySide={sideBySide} sideBySide={sideBySide}
showApp={hasApps}
selected={flow.id === selectedId} selected={flow.id === selectedId}
onSelect={select} onSelect={select}
onContextMenu={openMenu}
/> />
); );
})} })}
@@ -229,19 +268,38 @@ export function FlowList({ sideBySide, style, className }: Props) {
{newCount === 1 ? "1 new flow" : `${newCount} new flows`} {newCount === 1 ? "1 new flow" : `${newCount} new flows`}
</button> </button>
)} )}
<ContextMenu
triggerPosition={menu}
items={menu == null ? [] : flowActions(menu.flow)}
onClose={() => setMenu(null)}
/>
</div> </div>
</div> </div>
); );
} }
/** 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>
);
}
interface RowProps { interface RowProps {
flow: Flow; flow: Flow;
index: number; index: number;
start: number; start: number;
columns: string; columns: string;
sideBySide: boolean; sideBySide: boolean;
showApp: boolean;
selected: boolean; selected: boolean;
onSelect: (index: number) => void; onSelect: (index: number) => void;
onContextMenu: (index: number, e: MouseEvent) => void;
} }
const FlowRow = memo(function FlowRow({ const FlowRow = memo(function FlowRow({
@@ -250,8 +308,10 @@ const FlowRow = memo(function FlowRow({
start, start,
columns, columns,
sideBySide, sideBySide,
showApp,
selected, selected,
onSelect, onSelect,
onContextMenu,
}: RowProps) { }: RowProps) {
const { host, path, query } = splitUrl(flow.url); const { host, path, query } = splitUrl(flow.url);
const failed = flow.error != null; const failed = flow.error != null;
@@ -263,7 +323,10 @@ const FlowRow = memo(function FlowRow({
<div <div
role="option" role="option"
aria-selected={selected} aria-selected={selected}
onMouseDown={() => onSelect(index)} onMouseDown={(e) => {
if (e.button === 0) onSelect(index);
}}
onContextMenu={(e) => onContextMenu(index, e)}
style={{ height: ROW_HEIGHT, transform: `translateY(${start}px)` }} style={{ height: ROW_HEIGHT, transform: `translateY(${start}px)` }}
className={classNames( className={classNames(
columns, columns,
@@ -275,12 +338,24 @@ const FlowRow = memo(function FlowRow({
)} )}
> >
<span className="flex items-center justify-center"> <span className="flex items-center justify-center">
{failed ? ( {flow.savedRequestId != null ? (
<Icon icon="bookmark" size="xs" color="primary" title="Saved to Yaak" />
) : failed ? (
<Icon icon="circle_alert" size="xs" color="danger" /> <Icon icon="circle_alert" size="xs" color="danger" />
) : flow.responseTruncated ? ( ) : flow.responseTruncated ? (
<Icon icon="scissors" size="xs" color="notice" title="Body cut at the capture limit" /> <Icon icon="scissors" size="xs" color="notice" title="Body cut at the capture limit" />
) : null} ) : null}
</span> </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 /> <HttpMethodTagRaw method={flow.method} short colored />
<span className="text-text-subtle">{host}</span> <span className="text-text-subtle">{host}</span>
<span> <span>
+45 -16
View File
@@ -1,15 +1,26 @@
import { HeaderSize, IconButton, SidebarLayout, SplitLayout } from "@yaakapp-internal/ui"; import {
HeaderSize,
IconButton,
SidebarLayout,
SplitLayout,
type SplitLayoutLayout,
Toasts,
} from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useState } from "react"; import { useState } from "react";
import { useLocalStorage } from "react-use"; import { useLocalStorage } from "react-use";
import { flowLayoutAtom } from "../lib/atoms"; import { flowLayoutAtom } from "../lib/atoms";
import { searchWindowAtom, showFirstRunAtom, shownTotalAtom } from "../lib/store";
import { getOsType } from "../lib/tauri"; import { getOsType } from "../lib/tauri";
import { ClearFlowsButton } from "./ClearFlowsButton"; import { ClearFlowsButton } from "./ClearFlowsButton";
import { FirstRun } from "./FirstRun";
import { FlowDetail } from "./FlowDetail"; import { FlowDetail } from "./FlowDetail";
import { FlowList } from "./FlowList"; import { FlowList } from "./FlowList";
import { ProxyStatus } from "./ProxyStatus"; import { ProxyStatus } from "./ProxyStatus";
import { filteredCountAtom, selectedSidebarLabelAtom, Sidebar } from "./Sidebar"; import { SaveDialog } from "./SaveDialog";
import { selectedSidebarLabelAtom, Sidebar } from "./Sidebar";
import { TakeOverBanner } from "./TakeOverBanner";
export function ProxyLayout() { export function ProxyLayout() {
const os = getOsType(); const os = getOsType();
@@ -120,27 +131,45 @@ export function ProxyLayout() {
) )
} }
> >
<SplitLayout <div className="h-full flex flex-col min-h-0">
storageKey="proxy_detail" <TakeOverBanner />
className="gap-1" <div className="flex-1 min-h-0 grid grid-rows-[minmax(0,1fr)]">
layout={flowLayout} <FlowsOrFirstRun flowLayout={flowLayout} />
defaultRatio={0.55} </div>
firstSlot={({ style, orientation }) => ( </div>
<FlowList sideBySide={orientation === "horizontal"} style={style} className="pt-1" />
)}
secondSlot={({ style, orientation }) => (
<FlowDetail style={style} sideBySide={orientation === "horizontal"} />
)}
/>
</SidebarLayout> </SidebarLayout>
<SaveDialog />
<Toasts />
</div> </div>
); );
} }
/** Its own component, so new traffic re-renders the count rather than the whole layout */ /** Its own component, so new traffic re-renders the count rather than the whole layout */
function FlowCount() { function FlowCount() {
const count = useAtomValue(filteredCountAtom); 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.
*/
function FlowsOrFirstRun({ flowLayout }: { flowLayout: SplitLayoutLayout }) {
if (useAtomValue(showFirstRunAtom)) return <FirstRun />;
return ( return (
<span className="text-text-subtlest shrink-0">{count === 1 ? "1 flow" : `${count} flows`}</span> <SplitLayout
storageKey="proxy_detail"
className="gap-1"
layout={flowLayout}
defaultRatio={0.55}
firstSlot={({ style, orientation }) => (
<FlowList sideBySide={orientation === "horizontal"} style={style} className="pt-1" />
)}
secondSlot={({ style, orientation }) => (
<FlowDetail style={style} sideBySide={orientation === "horizontal"} />
)}
/>
); );
} }
+215 -32
View File
@@ -1,43 +1,135 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import type { CaStatus } from "@yaakapp-internal/proxy-lib"; import type { CaStatus, SystemProxyStatus } from "@yaakapp-internal/proxy-lib";
import { Button, Dropdown, type DropdownItem, type DropdownRef, Icon } from "@yaakapp-internal/ui"; import {
Button,
Checkbox,
Dropdown,
type DropdownItem,
type DropdownRef,
Icon,
} from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useAtomValue, useStore } from "jotai";
import type { HTMLAttributes, ReactNode, Ref } from "react"; import type { HTMLAttributes, ReactNode, Ref } from "react";
import { useRef } from "react"; import { useRef } from "react";
import { useRpcEvent } from "../hooks/useRpcEvent";
import { useRpcMutation } from "../hooks/useRpcMutation"; import { useRpcMutation } from "../hooks/useRpcMutation";
import { useRpcQuery } from "../hooks/useRpcQuery"; import { useRpcQuery } from "../hooks/useRpcQuery";
import { useRpcQueryWithEvent } from "../hooks/useRpcQueryWithEvent"; import { environmentExports } from "../lib/env";
import { proxyStateAtom, refreshProxyState } from "../lib/store";
import { recordingLabel, systemProxySwitch } from "../lib/systemProxy";
import { useShowToast } from "../lib/toast";
/** The header's state pill and its menu, with the certificate stepping out when untrusted */ /**
* The header's state pill and its menu: recording, the 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.
*/
export function ProxyStatus() { export function ProxyStatus() {
const queryClient = useQueryClient();
const dropdownRef = useRef<DropdownRef>(null); const dropdownRef = useRef<DropdownRef>(null);
const { data: proxyState } = useRpcQueryWithEvent("get_proxy_state", {}, "proxy_state_changed"); const proxyState = useAtomValue(proxyStateAtom);
const caStatus = useRpcQuery("ca_status", {}); const setup = useProxySetup();
const trust = useRpcMutation("ca_trust", {
onSuccess: (status) => queryClient.setQueryData<CaStatus>(["ca_status", {}], status),
});
const execute = useRpcMutation("execute_action"); const execute = useRpcMutation("execute_action");
const retry = useRpcMutation("take_over");
const setAtLaunch = useSetSystemProxyAtLaunch();
const copyEnvironment = useCopyEnvironment();
const { caStatus, trust, systemProxy, setSystemProxy } = setup;
const systemSwitch = useSystemProxySwitch(setup);
const running = proxyState?.state === "running"; const running = proxyState?.state === "running";
const port = proxyState?.port; const recording = proxyState?.recording !== false;
const other = proxyState?.otherOwner ?? null;
const port = proxyState?.port ?? other?.proxyPort ?? null;
const untrusted = caStatus.data != null && !caStatus.data.trusted; const untrusted = caStatus.data != null && !caStatus.data.trusted;
const stateRow =
other != null ? (
<MenuRow
icon={<StateDot state="cli" />}
title="Running from the CLI"
description={`Started by yaak proxy start, pid ${other.pid}, since ${timeOf(other.startedAt)}.${other.proxyPort == null ? "" : ` Port ${other.proxyPort}.`}`}
/>
) : running ? (
<MenuRow
icon={<StateDot state={recording ? "recording" : "paused"} />}
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."
}
/>
) : (
<MenuRow
icon={<Icon icon="alert_triangle" size="sm" color="warning" />}
title="Not running"
description={
<span className="text-danger">
{retry.error?.message ?? proxyState?.startError ?? "The proxy has not started."}
</span>
}
action={
<Button
size="2xs"
variant="border"
isLoading={retry.isPending}
onClick={(e) => {
e.stopPropagation();
retry.mutate({});
}}
>
Retry
</Button>
}
/>
);
const items: DropdownItem[] = [ const items: DropdownItem[] = [
{ type: "content", label: stateRow },
{ type: "separator", hidden: systemProxy.data?.supported !== true },
{ {
type: "content", type: "content",
hidden: systemProxy.data?.supported !== true,
label: ( label: (
<MenuRow <MenuRow
icon={<StateDot running={running} />} icon={<Icon icon="wifi" size="sm" color="secondary" />}
title={running && port != null ? `Running on port ${port}` : "Stopped"} title="Override system proxy"
description={ description={
running <span className={classNames(systemSwitch.failed && "text-danger")}>
? "Listening on 127.0.0.1." {systemSwitch.description}
: "Start the proxy to capture traffic from apps pointed at it." </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: "separator" }, {
type: "content",
hidden: systemProxy.data?.supported !== true,
label: (
<KeepMenuOpen className="block pl-[2.125rem] pr-1.5 pb-1.5">
<Checkbox
title={
<span className="text-xs text-text-subtle">Override system proxy at launch</span>
}
checked={proxyState?.systemProxyAtLaunch ?? true}
disabled={setAtLaunch.isPending}
onChange={(enabled) => setAtLaunch.mutate({ enabled })}
/>
</KeepMenuOpen>
),
},
{ {
type: "content", type: "content",
label: caStatus.data?.trusted ? ( label: caStatus.data?.trusted ? (
@@ -78,13 +170,17 @@ export function ProxyStatus() {
}, },
{ type: "separator" }, { type: "separator" },
{ {
label: running ? "Stop Proxy" : "Start Proxy", label: "Copy Environment for a Terminal",
icon: running ? "circle_stop" : "circle_play", icon: "square_terminal",
hotKeyAction: running ? "proxy_stop" : "proxy_start", onSelect: copyEnvironment,
},
{
label: recordingLabel(recording),
icon: recording ? "circle_pause" : "circle_play",
hidden: !running,
hotKeyAction: "recording_toggle",
hotKeyLabelOnly: true, hotKeyLabelOnly: true,
onSelect: () => { onSelect: () => execute.mutate({ scope: "global", action: "recording_toggle" }),
execute.mutate({ scope: "global", action: running ? "proxy_stop" : "proxy_start" });
},
}, },
]; ];
@@ -97,17 +193,25 @@ export function ProxyStatus() {
</Pill> </Pill>
)} )}
<Dropdown ref={dropdownRef} items={items}> <Dropdown ref={dropdownRef} items={items}>
<Pill> <Pill color={!running && other == null && proxyState != null ? "warning" : undefined}>
{running ? ( {other != null ? (
<> <>
<StateDot running /> <StateDot state="cli" />
<span className="text-text">Running</span> <span className="text-text">CLI</span>
{other.proxyPort != null && (
<span className="font-mono text-text-subtle">:{other.proxyPort}</span>
)}
</>
) : running ? (
<>
<StateDot state={recording ? "recording" : "paused"} />
<span className="text-text">{recording ? "Recording" : "Paused"}</span>
{port != null && <span className="font-mono text-text-subtle">:{port}</span>} {port != null && <span className="font-mono text-text-subtle">:{port}</span>}
</> </>
) : ( ) : (
<> <>
<StateDot running={false} /> <Icon icon="alert_triangle" size="xs" />
Stopped Not running
</> </>
)} )}
<Icon icon="chevron_down" size="xs" className="text-text-subtlest" /> <Icon icon="chevron_down" size="xs" className="text-text-subtlest" />
@@ -117,6 +221,83 @@ export function ProxyStatus() {
); );
} }
/**
* The certificate and the system proxy, read once and shared by the header and the first-run
* card, with the actions that change them.
*/
export function useProxySetup() {
const queryClient = useQueryClient();
const caStatus = useRpcQuery("ca_status", {});
const systemProxy = useRpcQuery("system_proxy_status", {});
const trust = useRpcMutation("ca_trust", {
mutationKey: ["ca_trust"],
onSuccess: (status) => queryClient.setQueryData<CaStatus>(["ca_status", {}], status),
});
const setSystemProxy = useRpcMutation("system_proxy_set", {
mutationKey: ["system_proxy_set"],
onSuccess: (status) => {
queryClient.setQueryData<SystemProxyStatus>(["system_proxy_status", {}], status);
},
});
// 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());
return { caStatus, trust, systemProxy, setSystemProxy };
}
/** The system proxy switch's state, from where it points and the change in flight */
export function useSystemProxySwitch(setup: ReturnType<typeof useProxySetup>) {
const proxyState = useAtomValue(proxyStateAtom);
const { systemProxy, setSystemProxy } = setup;
return systemProxySwitch({
routing: systemProxy.data?.enabled === true,
port: proxyState?.port ?? proxyState?.configuredPort ?? null,
pending: setSystemProxy.isPending ? (setSystemProxy.variables?.enabled ?? null) : null,
error: setSystemProxy.error?.message ?? null,
});
}
/** Saves whether the system proxy turns on as the app launches */
function useSetSystemProxyAtLaunch() {
const store = useStore();
return useRpcMutation("set_system_proxy_at_launch", {
onSettled: () => void refreshProxyState(store),
});
}
export function useCopyEnvironment() {
const proxyState = useAtomValue(proxyStateAtom);
const caStatus = useRpcQuery("ca_status", {});
const showToast = useShowToast();
return async () => {
const port =
proxyState?.port ?? proxyState?.otherOwner?.proxyPort ?? proxyState?.configuredPort;
const caPath = caStatus.data?.certPath;
if (port == null || caPath == null) return;
try {
await navigator.clipboard.writeText(environmentExports(port, caPath));
showToast({
message: "Copied the environment. Paste it into a terminal to proxy what runs there.",
});
} catch (err) {
showToast({ message: `Failed to copy: ${String(err)}`, color: "danger" });
}
};
}
/** Keeps a click on a control inside a menu row from closing the menu, as a row's click does */
function KeepMenuOpen({ children, className }: { children: ReactNode; className?: string }) {
return (
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- only stops the click reaching the row
<span className={className} onClick={(e) => e.stopPropagation()}>
{children}
</span>
);
}
function timeOf(iso: string): string {
return new Date(iso).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}
interface PillProps extends HTMLAttributes<HTMLButtonElement> { interface PillProps extends HTMLAttributes<HTMLButtonElement> {
color?: "warning"; color?: "warning";
ref?: Ref<HTMLButtonElement>; ref?: Ref<HTMLButtonElement>;
@@ -141,13 +322,15 @@ function Pill({ children, color, ref, ...props }: PillProps) {
); );
} }
function StateDot({ running }: { running: boolean }) { export function StateDot({ state }: { state: "recording" | "paused" | "cli" }) {
return ( return (
<span <span
aria-hidden aria-hidden
className={classNames( className={classNames(
"inline-block w-[7px] h-[7px] rounded-full shrink-0", "inline-block w-[7px] h-[7px] rounded-full shrink-0",
running ? "bg-success ring-3 ring-success/20" : "border-[1.5px] border-text-subtlest", state === "recording" && "bg-success ring-3 ring-success/20",
state === "cli" && "bg-info ring-3 ring-info/20",
state === "paused" && "border-[1.5px] border-text-subtlest",
)} )}
/> />
); );
@@ -165,7 +348,7 @@ function MenuRow({
action?: ReactNode; action?: ReactNode;
}) { }) {
return ( return (
<div className="grid grid-cols-[1rem_minmax(0,1fr)_auto] gap-x-3 items-start px-1.5 py-1"> <div className="grid grid-cols-[1rem_minmax(0,1fr)_auto] gap-x-3 items-start px-1.5 py-1 w-[24rem] max-w-full">
<span className="h-5 flex items-center justify-center">{icon}</span> <span className="h-5 flex items-center justify-center">{icon}</span>
<div> <div>
<div className="text-sm text-text">{title}</div> <div className="text-sm text-text">{title}</div>
+320
View File
@@ -0,0 +1,320 @@
import type {
DropReason,
Flow,
FolderSummary,
WorkspaceSummary,
} from "@yaakapp-internal/proxy-lib";
import {
Banner,
Dialog,
DialogFooter,
HttpMethodTagRaw,
LoadingIcon,
PlainInput,
} from "@yaakapp-internal/ui";
import { unnamedRequestName } from "@yaakapp-internal/ui/resolvedModelName";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { useLocalStorage } from "react-use";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { useRpcQuery } from "../hooks/useRpcQuery";
import { exportHar, saveDialogFlowAtom } from "../lib/flowActions";
import { useShowToast } from "../lib/toast";
const DOWNLOAD_URL = "https://yaak.app/download";
const SAVE_FORM_ID = "save-flow-to-yaak";
/** Save as Yaak request, for the flow in `saveDialogFlowAtom` */
export function SaveDialog() {
const [flow, setFlow] = useAtom(saveDialogFlowAtom);
const workspaces = useRpcQuery("list_workspaces", {}, { enabled: flow != null, staleTime: 0 });
const close = () => setFlow(null);
if (flow == null) return null;
if (workspaces.data?.yaakFound === false) return <NoYaakDialog flow={flow} onClose={close} />;
return (
<Dialog
open
onClose={close}
size="dynamic"
title="Save as Yaak request"
description="A new request in Yaak with this flow's method, URL, headers and body."
className="w-[36rem]"
>
{workspaces.error != null ? (
<Banner color="danger">{workspaces.error.message}</Banner>
) : workspaces.data == null ? (
<LoadingIcon />
) : (
<SaveForm flow={flow} workspaces={workspaces.data.workspaces} onClose={close} />
)}
</Dialog>
);
}
function SaveForm({
flow,
workspaces,
onClose,
}: {
flow: Flow;
workspaces: WorkspaceSummary[];
onClose: () => void;
}) {
const showToast = useShowToast();
const preview = useRpcQuery("preview_save", { flowId: flow.id }, { staleTime: 0 });
const [lastWorkspaceId, setLastWorkspaceId] = useLocalStorage<string>("save_workspace_id");
const [workspaceId, setWorkspaceId] = useState(
workspaces.find((w) => w.id === lastWorkspaceId)?.id ?? workspaces[0]?.id ?? "",
);
const [folderId, setFolderId] = useState("");
const [name, setName] = useState("");
const save = useRpcMutation("save_flow");
const workspace = workspaces.find((w) => w.id === workspaceId);
const folders = useMemo(() => folderOptions(workspace?.folders ?? []), [workspace]);
useEffect(() => setFolderId(""), [workspaceId]);
const p = preview.data;
// Unnamed unless one is typed, as Yaak leaves a new request, which then shows its URL.
const unnamed =
p == null
? ""
: unnamedRequestName({ model: "http_request", url: p.requestUrl, bodyType: p.bodyType });
const folderName = folders.find((f) => f.id === folderId)?.name;
const submit = () => {
if (workspace == null) return;
save.mutate(
{
flowId: flow.id,
workspaceId: workspace.id,
...(folderId === "" ? {} : { folderId }),
...(name.trim() === "" ? {} : { name }),
},
{
onSuccess: (saved) => {
setLastWorkspaceId(workspace.id);
onClose();
showToast({
message: (
<>
Saved <b className="font-semibold">{saved.name || unnamed}</b> to {workspace.name}
{folderName == null ? "." : `, in ${folderName}.`} Yaak picks it up within a second.
</>
),
});
},
},
);
};
if (workspaces.length === 0) {
return (
<Banner color="notice">
Yaak has no workspaces yet. Create one in Yaak, then save here.
</Banner>
);
}
if (p == null) {
return preview.error != null ? (
<Banner color="danger">{preview.error.message}</Banner>
) : (
<LoadingIcon />
);
}
return (
<form
id={SAVE_FORM_ID}
className="flex flex-col gap-3.5 pb-2"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<div className="flex items-center gap-3 h-sm px-2.5 rounded-md border border-border-subtle font-mono text-sm min-w-0">
<HttpMethodTagRaw method={flow.method} colored />
<span className="truncate text-text">{flow.url}</span>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Workspace">
<select
className={inputClasses}
value={workspaceId}
onChange={(e) => setWorkspaceId(e.target.value)}
>
{workspaces.map((w) => (
<option key={w.id} value={w.id}>
{w.name}
</option>
))}
</select>
</Field>
<Field label="Folder">
<select
className={inputClasses}
value={folderId}
onChange={(e) => setFolderId(e.target.value)}
>
<option value="">No folder</option>
{folders.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
</Field>
</div>
<PlainInput label="Name" autoFocus defaultValue="" placeholder={unnamed} onChange={setName} />
{p.droppedHeaders.length > 0 && (
<div className="rounded-md border border-border-subtle px-3.5 py-2.5">
<div className="text-sm text-text">
{p.droppedHeaders.length === 1
? "1 header left out"
: `${p.droppedHeaders.length} headers left out`}{" "}
<span className="text-text-subtlest text-xs">
{droppedSummary(p.droppedHeaders.map((h) => h.reason))}
</span>
</div>
<div className="flex flex-wrap gap-1.5 mt-2">
{p.droppedHeaders.map((h) => (
<span
key={h.name}
title={`${h.name}: ${h.value}\n${reasonLabel(h.reason)}`}
className="font-mono text-xs leading-[22px] px-1.5 rounded-sm border border-border-subtle text-text-subtle line-through decoration-text-subtlest"
>
{h.name}
</span>
))}
</div>
</div>
)}
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3.5 gap-y-1.5 text-sm">
<span className="text-text-subtle">Kept</span>
<span>
{p.keptHeaders === 1 ? "1 header" : `${p.keptHeaders} headers`}
{p.body == null ? ", and no body" : `, and the body as ${p.body}`}
</span>
{p.warnings.map((w) => (
<NoteRow key={w} warning={w} authorization={p.authorization} />
))}
</div>
{save.error != null && <Banner color="danger">{save.error.message}</Banner>}
<DialogFooter
leftSlot={
<span className="text-xs text-text-subtlest">Yaak picks it up within a second</span>
}
actions={[
{ label: "Cancel", onClick: onClose },
{
label: "Save request",
form: SAVE_FORM_ID,
color: "primary",
isLoading: save.isPending,
disabled: workspace == null,
},
]}
/>
</form>
);
}
function NoteRow({ warning, authorization }: { warning: string; authorization: boolean }) {
const flagged = authorization && warning.includes("Authorization");
return (
<>
<span className="text-text-subtle">Note</span>
<span className={flagged ? "text-notice" : "text-text-subtle"}>{warning}</span>
</>
);
}
/** Without Yaak there is nothing to save into: say so, and offer HAR as a way to keep the flow */
function NoYaakDialog({ flow, onClose }: { flow: Flow; onClose: () => void }) {
const showToast = useShowToast();
return (
<Dialog open onClose={onClose} size="dynamic" className="w-[32rem]">
<DialogFooter
leftSlot={<span className="text-xs text-text-subtlest">Or keep the flow as a file</span>}
actions={[
{
label: "Export HAR",
onClick: async () => {
try {
if ((await exportHar({ ids: [flow.id] }, "flow.har")) != null) {
onClose();
showToast({ message: "Exported the flow as HAR" });
}
} catch (err) {
showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" });
}
},
},
{ label: "Download Yaak", color: "primary", onClick: () => void openUrl(DOWNLOAD_URL) },
]}
/>
<div className="pt-6 pb-2">
<div className="text-lg font-semibold mb-1.5">Saving needs Yaak</div>
<div className="text-sm text-text-subtle leading-relaxed">
This flow becomes a request in one of Yaak's workspaces, and Yaak has not been opened on
this Mac. Yaak is free for personal use and opens the request with the same viewers you
see here.
</div>
</div>
</Dialog>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
// oxlint-disable-next-line jsx-a11y/label-has-associated-control -- wraps its control
<label className="flex flex-col gap-1 min-w-0">
<span className="text-xs text-text-subtle">{label}</span>
{children}
</label>
);
}
const inputClasses =
"h-sm w-full min-w-0 px-2 rounded-md border border-border bg-surface text-text text-sm outline-hidden focus:border-border-focus";
/** Folders by their path within the workspace, "Stripe / Payments", in path order */
function folderOptions(folders: FolderSummary[]): { id: string; name: string }[] {
const byId = new Map(folders.map((f) => [f.id, f]));
const pathOf = (f: FolderSummary): string => {
const names = [f.name];
const seen = new Set([f.id]);
let parent = f.folderId == null ? undefined : byId.get(f.folderId);
while (parent != null && !seen.has(parent.id)) {
names.unshift(parent.name);
seen.add(parent.id);
parent = parent.folderId == null ? undefined : byId.get(parent.folderId);
}
return names.join(" / ");
};
return folders
.map((f) => ({ id: f.id, name: pathOf(f) }))
.sort((a, b) => a.name.localeCompare(b.name));
}
function reasonLabel(reason: DropReason): string {
switch (reason) {
case "set_by_sending":
return "Yaak sets it when it sends";
case "hop_by_hop":
return "Only meant for the proxy";
case "body_decoded":
return "The body is saved decoded";
}
}
function droppedSummary(reasons: DropReason[]): string {
const unique = [...new Set(reasons)];
if (unique.length === 1 && unique[0] === "set_by_sending") {
return "Yaak sets these itself when it sends";
}
return "Yaak sets these itself, or they only meant something to the proxy";
}
+111
View File
@@ -0,0 +1,111 @@
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";
/** 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.
*/
export const SearchBar = memo(function SearchBar() {
const [text, setText] = useState("");
const setTerms = useSetAtom(searchTermsAtom);
const query = useAtomValue(activeQueryAtom);
const showToast = useShowToast();
// 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);
return () => clearTimeout(timer);
}, [text, setTerms]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "f") {
e.preventDefault();
inputRef.current?.focus();
inputRef.current?.select();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
// The field is replaced to empty it, so focus moves to the new one.
const clear = () => {
setText("");
setTerms(null);
setResetKey((k) => k + 1);
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>
);
});
+47 -40
View File
@@ -4,19 +4,22 @@ import { selectedIdsFamily, Tree } from "@yaakapp-internal/ui";
import { atom, useAtomValue } from "jotai"; import { atom, useAtomValue } from "jotai";
import { atomWithStorage } from "jotai/utils"; import { atomWithStorage } from "jotai/utils";
import { useCallback } from "react"; 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 { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse";
import { flowsAtom, flowTotalAtom } from "../lib/store"; import { flowsAtom, flowTotalAtom } from "../lib/store";
import { AppIcon } from "./AppIcon";
/** A node in the sidebar tree — either a domain or a path segment. */ /** A node in the sidebar tree: a domain, a path segment, or an app. */
export type SidebarItem = { export type SidebarItem = {
id: string; id: string;
label: string; label: string;
/** Flows held under this node; for the Domains folder, every flow in the database */ /** Flows held under this node; for the Domains folder, every flow in the database */
count: number; count: number;
/** An app's path, for its icon */
appPath?: string;
}; };
export const SIDEBAR_TREE_ID = "proxy-sidebar";
const sidebarTreeAtom = atom<TreeNode<SidebarItem>>((get) => { const sidebarTreeAtom = atom<TreeNode<SidebarItem>>((get) => {
const exchanges = get(flowsAtom); const exchanges = get(flowsAtom);
return buildTree(exchanges, get(flowTotalAtom)); return buildTree(exchanges, get(flowTotalAtom));
@@ -43,34 +46,15 @@ const collapsedAtom = atom(
}, },
); );
/** The sidebar nodes selected, or null when the list shows all traffic */
const selectedNodeIdsAtom = atom((get) => {
const selectedIds = get(selectedIdsFamily(SIDEBAR_TREE_ID));
// Nothing selected, or a node holding everything → show all
if (selectedIds.length === 0 || selectedIds.includes("root") || selectedIds.includes("domains")) {
return null;
}
return new Set(selectedIds);
});
/** Exchanges filtered by the currently selected sidebar node(s). */
export const filteredExchangesAtom = atom((get) => {
const exchanges = get(flowsAtom);
const selected = get(selectedNodeIdsAtom);
if (selected == null) return exchanges;
return exchanges.filter((ex) => nodeIdsOf(ex).some((id) => selected.has(id)));
});
/** Flows in the database for all traffic, or those held under the selected nodes */
export const filteredCountAtom = atom((get) =>
get(selectedNodeIdsAtom) == null ? get(flowTotalAtom) : get(filteredExchangesAtom).length,
);
/** The selected sidebar node's name, for the header */ /** The selected sidebar node's name, for the header */
export const selectedSidebarLabelAtom = atom((get) => { export const selectedSidebarLabelAtom = atom((get) => {
const selectedId = get(selectedIdsFamily(SIDEBAR_TREE_ID))[0]; const selectedId = get(selectedIdsFamily(SIDEBAR_TREE_ID))[0];
if (selectedId?.startsWith("domain:")) return selectedId.slice("domain:".length); if (selectedId?.startsWith("domain:")) return selectedId.slice("domain:".length);
if (selectedId?.startsWith("path:")) return selectedId.slice("path:".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"; return "All traffic";
}); });
@@ -83,21 +67,15 @@ const nodeIdsByFlow = new WeakMap<Flow, string[]>();
function nodeIdsOf(ex: Flow): string[] { function nodeIdsOf(ex: Flow): string[] {
let ids = nodeIdsByFlow.get(ex); let ids = nodeIdsByFlow.get(ex);
if (ids != null) return ids; if (ids != null) return ids;
let hostname: string; // By the rules the database filters by, so a node shows the flows it counts.
let segments: string[]; const path = sidebarPath(ex.url);
try { const schemeless = !ex.url.includes("://");
const url = new URL(ex.url); const [hostname = "", ...segments] = schemeless ? [path] : path.split("/");
hostname = url.host;
segments = url.pathname.split("/").filter(Boolean);
} catch {
hostname = ex.url;
segments = [];
}
ids = [`domain:${hostname}`]; ids = [`domain:${hostname}`];
let path = `path:${hostname}`; let node = `path:${hostname}`;
for (const seg of segments) { for (const seg of segments) {
path += `/${seg}`; node += `/${seg}`;
ids.push(path); ids.push(node);
} }
nodeIdsByFlow.set(ex, ids); nodeIdsByFlow.set(ex, ids);
return ids; return ids;
@@ -197,6 +175,34 @@ function buildTree(exchanges: Flow[], total: number): TreeNode<SidebarItem> {
domainsFolder.children?.push(toTreeNode(domain, domainsFolder, 2)); 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); rootNode.children?.push(domainsFolder);
return rootNode; return rootNode;
@@ -206,6 +212,7 @@ function ItemInner({ item }: { item: SidebarItem }) {
const count = item.count; const count = item.count;
return ( return (
<div className="flex items-center gap-2 w-full min-w-0"> <div className="flex items-center gap-2 w-full min-w-0">
{item.appPath != null && <AppIcon path={item.appPath} />}
<span className="truncate">{item.label}</span> <span className="truncate">{item.label}</span>
{count > 0 && <span className="ml-auto text-text-subtlest text-2xs shrink-0">{count}</span>} {count > 0 && <span className="ml-auto text-text-subtlest text-2xs shrink-0">{count}</span>}
</div> </div>
@@ -214,9 +221,9 @@ function ItemInner({ item }: { item: SidebarItem }) {
export function Sidebar() { export function Sidebar() {
const tree = useAtomValue(sidebarTreeAtom); const tree = useAtomValue(sidebarTreeAtom);
const treeId = SIDEBAR_TREE_ID;
const getItemKey = useCallback((item: SidebarItem) => `${item.id}:${item.count}`, []); const getItemKey = useCallback((item: SidebarItem) => `${item.id}:${item.count}`, []);
const treeId = SIDEBAR_TREE_ID;
return ( return (
// The tree scrolls itself, in a row of bounded height, so its virtualizer mounts only the rows // The tree scrolls itself, in a row of bounded height, so its virtualizer mounts only the rows
@@ -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 (
<div className="flex items-center gap-2.5 h-[2.2rem] mx-2 mb-1 pl-3 pr-1.5 rounded-md bg-surface-highlight/45 border border-border-subtle text-sm text-text-subtle min-w-0">
<Icon icon="square_terminal" size="sm" color="info" className="shrink-0" />
<span className="text-text shrink-0">{who}</span>
{other.owner === "cli" && (
<span className="font-mono text-xs text-text-subtlest shrink-0">yaak proxy start</span>
)}
<span className="truncate">
{!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 ? (
<span className="text-danger">{takeOver.error.message}</span>
) : (
`pid ${other.pid}, since ${since}. Flows still list here, read from the capture database.`
)}
</span>
{cli && (
<Button
size="2xs"
variant="border"
className="ml-auto shrink-0"
isLoading={takeOver.isPending}
onClick={() => takeOver.mutate({})}
>
Take over
</Button>
)}
</div>
);
}
+3
View File
@@ -3,6 +3,9 @@ import type { SplitLayoutLayout } from "@yaakapp-internal/ui";
import { atom } from "jotai"; import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils"; 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 */ /** The selected flow as last seen, kept once the window has dropped it so the detail stays */
export const selectedFlowAtom = atom<Flow | null>(null); export const selectedFlowAtom = atom<Flow | null>(null);
+94
View File
@@ -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();
});
});
+68
View File
@@ -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<Flow, "method" | "url" | "requestHeaders">, 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 ")}`;
}
+19
View File
@@ -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"),
);
});
});
+21
View File
@@ -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");
}
+121
View File
@@ -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<Flow | null>(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<string | null> {
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<ExportHarRequest, "path">,
defaultName: string,
): Promise<number | null> {
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 | null> | 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],
);
}
+143
View File
@@ -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> = {}): 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();
});
});
+116
View File
@@ -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<FlowFilter, "q" | "under" | "app">;
/** 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;
}
+231 -22
View File
@@ -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 { atom, type createStore } from "jotai";
import { followingAtom, selectedFlowAtom } from "./atoms"; import { followingAtom, SIDEBAR_TREE_ID, selectedFlowAtom } from "./atoms";
import { import {
applyChanges, applyChanges,
emptyWindow, emptyWindow,
@@ -12,11 +13,18 @@ import {
windowFromPage, windowFromPage,
} from "./flowWindow"; } from "./flowWindow";
import { listen, rpc } from "./rpc"; import { listen, rpc } from "./rpc";
import { type FlowQuery, matchesQuery, selectionQuery } from "./search";
type Store = ReturnType<typeof createStore>;
/** How long new flows gather before they reach the list, so a burst renders once */ /** How long new flows gather before they reach the list, so a burst renders once */
const FLUSH_MS = 100; const FLUSH_MS = 100;
/** How long after new flows the total is counted again from the database */ /** How long after new flows the total is counted again from the database */
const RECOUNT_MS = 1000; 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 */ /** The flows in memory: a window onto the database, which keeps them all */
export const flowWindowAtom = atom<FlowWindow>(emptyWindow); export const flowWindowAtom = atom<FlowWindow>(emptyWindow);
@@ -27,23 +35,73 @@ export const flowsAtom = atom((get) => get(flowWindowAtom).flows);
/** Start of the newest flow held, across all traffic */ /** Start of the newest flow held, across all traffic */
export const newestStartedAtAtom = atom((get) => get(flowsAtom).at(-1)?.startedAt ?? null); 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 */ /** Flows in the database */
export const flowTotalAtom = atom((get) => get(flowWindowAtom).total); 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<string | null>(null);
/** 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 };
});
/** 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<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);
/** 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 */ /** Follows new traffic, dropping the older flows scrolling up loaded, or holds the list */
export const followAtom = atom( export const followAtom = atom(
(get) => get(followingAtom), (get) => get(followingAtom),
(_get, set, following: boolean) => { (_get, set, following: boolean) => {
set(followingAtom, following); 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<GetProxyStateResponse | null>(null);
/** /**
* Changes applied while a page of older flows loads, or null when none is loading. A slow flow * 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 * 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; 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) => { 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]; const first = flows[0];
if (changesDuringPage != null || !hasOlder || first == null) return; if (changesDuringPage != null || !hasOlder || first == null) return;
const changes: ModelPayload[] = []; const changes: ModelPayload[] = [];
changesDuringPage = changes; changesDuringPage = changes;
try { try {
const res = await rpc("list_flows", { before: first.id, limit: PAGE_SIZE }); const res = await rpc("list_flows", { ...search?.query, before: first.id, limit: PAGE_SIZE });
// Dropped when the window moved on meanwhile, trimmed or cleared. // Dropped when the window moved on meanwhile, trimmed, cleared or searched again.
set(flowWindowAtom, (win) => { const merge = (win: FlowWindow) => {
if (win.flows[0]?.id !== first.id) return win; if (win.flows[0]?.id !== first.id) return win;
const merged = prependOlder(win, res.flows, PAGE_SIZE); const merged = prependOlder(win, res.flows, PAGE_SIZE);
const kept = search == null ? changes : changesFor(search.query, changes);
// Counted when they first applied. // 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) { } catch (err) {
console.warn("Failed to load older flows", err); console.warn("Failed to load older flows", err);
} finally { } 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 * Loads the newest flows, then keeps them current with the engine's events, or with reads of the
* `FLUSH_MS` and apply together, and the window drops its oldest flows while the list follows, * database while another process owns the engine. Events gather for `FLUSH_MS` and apply
* so a new flow costs the same however many the database holds. * 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<typeof createStore>) { export function syncFlows(store: Store) {
let pending: ModelPayload[] = []; let pending: ModelPayload[] = [];
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null;
let loaded = false; let loaded = false;
let loads = 0; 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() { function flush() {
timer = null; timer = null;
@@ -91,9 +166,17 @@ export function syncFlows(store: ReturnType<typeof createStore>) {
const changes = pending; const changes = pending;
pending = []; pending = [];
changesDuringPage?.push(...changes); changesDuringPage?.push(...changes);
const following = store.get(followingAtom);
store.set(flowWindowAtom, (win) => { store.set(flowWindowAtom, (win) => {
const next = applyChanges(win, changes); 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 selected = store.get(selectedFlowAtom);
const change = changes.findLast((c) => c.model.id === selected?.id); const change = changes.findLast((c) => c.model.id === selected?.id);
@@ -103,6 +186,11 @@ export function syncFlows(store: ReturnType<typeof createStore>) {
if (recountTimer == null) recountTimer = setTimeout(recount, RECOUNT_MS); 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 // 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. // around it can count twice or not at all; a count from the database a moment later settles it.
let recountTimer: ReturnType<typeof setTimeout> | null = null; let recountTimer: ReturnType<typeof setTimeout> | null = null;
@@ -115,11 +203,19 @@ export function syncFlows(store: ReturnType<typeof createStore>) {
win.total === res.total ? win : { ...win, total: res.total }, 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) => { listen("model_write", (payload) => {
pending.push(payload); seenId = payload.model.id;
if (timer == null) timer = setTimeout(flush, FLUSH_MS); 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 delete and the event.
@@ -138,10 +234,123 @@ export function syncFlows(store: ReturnType<typeof createStore>) {
// A clear since asked for a newer page. // A clear since asked for a newer page.
if (thisLoad !== loads) return; if (thisLoad !== loads) return;
store.set(flowWindowAtom, windowFromPage(res.flows, WINDOW_SIZE, res.total)); store.set(flowWindowAtom, windowFromPage(res.flows, WINDOW_SIZE, res.total));
seenId = res.flows[0]?.id ?? null;
loaded = true; loaded = true;
store.set(flowsLoadedAtom, true);
flush(); 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(); 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", {}));
}
+45
View File
@@ -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");
});
});
+51
View File
@@ -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";
}
+12
View File
@@ -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],
);
}
+2 -1
View File
@@ -6,12 +6,13 @@ import { type ReactNode, StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { ProxyLayout } from "./components/ProxyLayout"; import { ProxyLayout } from "./components/ProxyLayout";
import { useHotkeyContextValue } from "./lib/hotkeys"; import { useHotkeyContextValue } from "./lib/hotkeys";
import { syncFlows } from "./lib/store"; import { syncFlows, syncProxyState } from "./lib/store";
import "./main.css"; import "./main.css";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const jotaiStore = createStore(); const jotaiStore = createStore();
syncProxyState(jotaiStore);
syncFlows(jotaiStore); syncFlows(jotaiStore);
function Hotkeys({ children }: { children: ReactNode }) { function Hotkeys({ children }: { children: ReactNode }) {
+2
View File
@@ -12,6 +12,8 @@
"@tanstack/react-query": "^5.104.2", "@tanstack/react-query": "^5.104.2",
"@tanstack/react-virtual": "^3.14.17", "@tanstack/react-virtual": "^3.14.17",
"@tauri-apps/api": "^2.12.3", "@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", "@tauri-apps/plugin-os": "^2.4.0",
"@yaakapp-internal/proxy-lib": "^1.0.0", "@yaakapp-internal/proxy-lib": "^1.0.0",
"@yaakapp-internal/theme": "^1.0.0", "@yaakapp-internal/theme": "^1.0.0",
+22 -1
View File
@@ -17,7 +17,7 @@ include_dir = "0.7"
r2d2 = "0.8.10" r2d2 = "0.8.10"
r2d2_sqlite = "0.32" r2d2_sqlite = "0.32"
reqwest = { workspace = true, features = ["json"] } 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"] } sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
@@ -37,6 +37,27 @@ yaak-templates = { workspace = true }
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2" 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] [dev-dependencies]
base64 = "0.23.1" base64 = "0.23.1"
bytes = "1" bytes = "1"
+17 -1
View File
@@ -44,7 +44,23 @@ elapsedMs: number | null, error: string | null,
/** /**
* The id of the Yaak request this flow was last saved as. * 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"; export type FlowBody = "request" | "response";
+153 -4
View File
@@ -5,6 +5,12 @@ export type ActionInvocation = { "scope": "global", action: GlobalAction, };
export type ActionMetadata = { label: string, defaultHotkey: string | null, }; export type ActionMetadata = { label: string, defaultHotkey: string | null, };
export type AppIconRequest = {
/**
* A flow's `clientPath`.
*/
path: string, };
export type CaStatus = { export type CaStatus = {
/** /**
* macOS trusts this app's CA, so HTTPS flows can be decrypted. * macOS trusts this app's CA, so HTTPS flows can be decrypted.
@@ -21,6 +27,37 @@ export type CaTrustRequest = Record<string, never>;
export type ClearFlowsRequest = Record<string, never>; export type ClearFlowsRequest = Record<string, never>;
/**
* 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<string>, filter?: FlowFilter, };
export type FilterBodyRequest = { flowId: string, which: FlowBody, export type FilterBodyRequest = { flowId: string, which: FlowBody,
/** /**
* JSONPath, or XPath for an XML or HTML body. * 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. * Which flows a listing or a HAR export covers. Every field narrows it; none is all of them.
*/ */
export type FlowFilter = { 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`. * Part of the host, any case: `stripe` matches `api.stripe.com`.
*/ */
host?: 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.
*/
under?: Array<string>,
/**
* Only flows from one of these apps, by bundle id, or by path for a process outside an
* app bundle.
*/
app?: Array<string>,
/** /**
* The method, any case. * The method, any case.
*/ */
@@ -68,6 +120,12 @@ before?: string, };
export type FlowsClearedPayload = Record<string, never>; export type FlowsClearedPayload = Record<string, never>;
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 FormatBodyRequest = { text: string, };
export type GetFlowBodyRequest = { flowId: string, which: FlowBody, }; 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. * 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<string, never>; export type ListActionsRequest = Record<string, never>;
@@ -108,13 +187,33 @@ flows: Array<Flow>,
/** /**
* Flows in the database, whatever the filter. * 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<string, never>;
export type ListWorkspacesResponse = {
/**
* Yaak has run on this machine. Without it there is nothing to save into.
*/
yaakFound: boolean, workspaces: Array<WorkspaceSummary>, };
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. * The process that runs the engine.
*/ */
export type Owner = "app" | "cli"; export type Owner = "app" | "cli";
export type PreviewSaveRequest = { flowId: string, };
export type ProxySetting = { enabled: boolean, server: string, port: number, }; export type ProxySetting = { enabled: boolean, server: string, port: number, };
export type ProxyState = "running" | "stopped"; 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 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<DroppedHeader>,
/**
* 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<string>,
/**
* An Authorization header is saved in plain text.
*/
authorization: boolean, };
export type SavedFlow = { requestId: string, name: string, };
export type ServiceProxy = { export type ServiceProxy = {
/** /**
@@ -131,6 +272,10 @@ export type ServiceProxy = {
*/ */
name: string, http: ProxySetting, https: ProxySetting, }; name: string, http: ProxySetting, https: ProxySetting, };
export type SetRecordingRequest = { enabled: boolean, };
export type SetSystemProxyAtLaunchRequest = { enabled: boolean, };
/** /**
* How a change gets administrator rights. * How a change gets administrator rights.
*/ */
@@ -153,3 +298,7 @@ port: number, services: Array<ServiceProxy>, backend: SystemProxyBackend,
supported: boolean, }; supported: boolean, };
export type SystemProxyStatusRequest = Record<string, never>; export type SystemProxyStatusRequest = Record<string, never>;
export type TakeOverRequest = Record<string, never>;
export type WorkspaceSummary = { id: string, name: string, folders: Array<FolderSummary>, };
@@ -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);
+6 -12
View File
@@ -5,8 +5,8 @@ use ts_rs::TS;
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_rpc.ts")] #[ts(export, export_to = "gen_rpc.ts")]
pub enum GlobalAction { pub enum GlobalAction {
ProxyStart, /// Pauses or resumes storing flows.
ProxyStop, RecordingToggle,
FlowsClear, FlowsClear,
} }
@@ -33,17 +33,11 @@ fn default_hotkey(mac: &str, other: &str) -> Option<String> {
pub fn all_global_actions() -> Vec<(ActionInvocation, ActionMetadata)> { pub fn all_global_actions() -> Vec<(ActionInvocation, ActionMetadata)> {
vec![ vec![
( (
ActionInvocation::Global { action: GlobalAction::ProxyStart }, ActionInvocation::Global { action: GlobalAction::RecordingToggle },
ActionMetadata { ActionMetadata {
label: "Start Proxy".into(), label: "Pause or Resume Recording".into(),
default_hotkey: default_hotkey("Meta+Shift+P", "Ctrl+Shift+P"), // Not Cmd+Shift+R, which reloads the window in development builds.
}, default_hotkey: default_hotkey("Meta+R", "Ctrl+Shift+R"),
),
(
ActionInvocation::Global { action: GlobalAction::ProxyStop },
ActionMetadata {
label: "Stop Proxy".into(),
default_hotkey: default_hotkey("Meta+Shift+S", "Ctrl+Shift+S"),
}, },
), ),
( (
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -5,7 +5,8 @@
use crate::bodies::{self, DecodedBody}; use crate::bodies::{self, DecodedBody};
use crate::ca::CaStatus; use crate::ca::CaStatus;
use crate::control::{ use crate::control::{
self, ControlInfo, ControlStatus, ErrorBody, StartRequest, SystemProxyRequest, TRUNCATED_HEADER, self, ControlInfo, ControlStatus, ErrorBody, RecordingRequest, StartRequest,
SystemProxyRequest, TRUNCATED_HEADER,
}; };
use crate::db::ProxyQueryManager; use crate::db::ProxyQueryManager;
use crate::flows::{self, FlowDetail, FlowFilter}; use crate::flows::{self, FlowDetail, FlowFilter};
@@ -127,6 +128,11 @@ impl ControlClient {
self.post("/ca/trust", &serde_json::json!({})).await self.post("/ca/trust", &serde_json::json!({})).await
} }
/// Resumes or pauses storing flows in the owner's engine.
pub async fn set_recording(&self, enabled: bool) -> Result<ControlStatus, String> {
self.post("/recording", &RecordingRequest { enabled }).await
}
pub async fn system_proxy(&self) -> Result<SystemProxyStatus, String> { pub async fn system_proxy(&self) -> Result<SystemProxyStatus, String> {
self.get("/system-proxy").await self.get("/system-proxy").await
} }
+20 -3
View File
@@ -205,6 +205,8 @@ pub struct ControlStatus {
pub proxy_port: Option<u16>, pub proxy_port: Option<u16>,
/// The port the engine starts on. /// The port the engine starts on.
pub configured_port: u16, pub configured_port: u16,
/// Flows are stored. Paused, traffic still passes through, unrecorded.
pub recording: bool,
pub flow_count: usize, pub flow_count: usize,
pub ca: CaStatus, pub ca: CaStatus,
/// `None` when it could not be read. /// `None` when it could not be read.
@@ -222,6 +224,11 @@ pub struct SystemProxyRequest {
pub enabled: bool, pub enabled: bool,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordingRequest {
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClearResponse { pub struct ClearResponse {
/// How many flows were deleted. /// How many flows were deleted.
@@ -344,11 +351,14 @@ impl ControlServer {
} }
pub(crate) fn already_owned(owner: &ControlInfo) -> String { pub(crate) fn already_owned(owner: &ControlInfo) -> String {
let who = match owner.owner { format!("The proxy is already running in {} (pid {})", owner_name(owner.owner), owner.pid)
}
pub(crate) fn owner_name(owner: Owner) -> &'static str {
match owner {
Owner::App => "Yaak Proxy", Owner::App => "Yaak Proxy",
Owner::Cli => "the yaak CLI", Owner::Cli => "the yaak CLI",
}; }
format!("The proxy is already running in {who} (pid {})", owner.pid)
} }
impl Drop for ControlServer { impl Drop for ControlServer {
@@ -472,6 +482,11 @@ async fn route(state: &ServerState, req: Request<Incoming>) -> Result<ControlRes
let _ = state.stop_tx.send(()); let _ = state.stop_tx.send(());
json(&status(ctx).await?) json(&status(ctx).await?)
} }
(&Method::POST, ["recording"]) => {
let body: RecordingRequest = read_json(req).await?;
ctx.set_recording(body.enabled);
json(&status(ctx).await?)
}
(&Method::GET, ["flows"]) => { (&Method::GET, ["flows"]) => {
let filter = FlowFilter::from_query(&query).map_err(bad_request)?; let filter = FlowFilter::from_query(&query).map_err(bad_request)?;
json(&flows::list(&ctx.db, &filter)?) json(&flows::list(&ctx.db, &filter)?)
@@ -509,6 +524,7 @@ async fn route(state: &ServerState, req: Request<Incoming>) -> Result<ControlRes
let ca = CaStore::load_or_create(&ctx.ca_dir()) let ca = CaStore::load_or_create(&ctx.ca_dir())
.map_err(|e| format!("Failed to load proxy CA: {e}"))?; .map_err(|e| format!("Failed to load proxy CA: {e}"))?;
ca::trust(ca.cert_path()).await.map_err(bad_request)?; ca::trust(ca.cert_path()).await.map_err(bad_request)?;
ctx.forget_passthrough();
json(&ca::status(ca.cert_path()).await?) json(&ca::status(ca.cert_path()).await?)
} }
(&Method::GET, ["system-proxy"]) => { (&Method::GET, ["system-proxy"]) => {
@@ -541,6 +557,7 @@ async fn status(ctx: &ProxyCtx) -> Result<ControlStatus, ApiError> {
state: if proxy_port.is_some() { ProxyState::Running } else { ProxyState::Stopped }, state: if proxy_port.is_some() { ProxyState::Running } else { ProxyState::Stopped },
proxy_port, proxy_port,
configured_port: ctx.settings()?.port, configured_port: ctx.settings()?.port,
recording: ctx.recording(),
flow_count: flows::count(&ctx.db)?, flow_count: flows::count(&ctx.db)?,
ca, ca,
system_proxy, system_proxy,
+9 -16
View File
@@ -7,6 +7,7 @@ use crate::models::{Flow, find_header};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use ts_rs::TS;
use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter}; use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter};
// The client's body types (`apps/yaak-client/lib/model_util.ts`). // The client's body types (`apps/yaak-client/lib/model_util.ts`).
@@ -28,16 +29,18 @@ const HOP_BY_HOP: &[&str] = &[
/// Yaak works these out itself when it sends the request. /// Yaak works these out itself when it sends the request.
const AUTOMATIC: &[&str] = &["host", "content-length", "accept-encoding"]; const AUTOMATIC: &[&str] = &["host", "content-length", "accept-encoding"];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct DroppedHeader { pub struct DroppedHeader {
pub name: String, pub name: String,
pub value: String, pub value: String,
pub reason: DropReason, pub reason: DropReason,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_rpc.ts")]
pub enum DropReason { pub enum DropReason {
/// Only meant for the hop between the app and the proxy. /// Only meant for the hop between the app and the proxy.
HopByHop, HopByHop,
@@ -72,8 +75,8 @@ pub fn flow_to_request(flow: &Flow, body: &DecodedBody) -> Conversion {
)); ));
} }
// No name, as in Yaak, which then shows the URL.
let request = HttpRequest { let request = HttpRequest {
name: default_name(&url),
method: flow.method.clone(), method: flow.method.clone(),
url, url,
url_parameters, url_parameters,
@@ -228,16 +231,6 @@ fn graphql_body(flow: &Flow, text: &str) -> Option<BTreeMap<String, Value>> {
Some(fields) Some(fields)
} }
/// The path, or the host for a request to the root.
fn default_name(url: &str) -> String {
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
match rest.split_once('/') {
Some((_, path)) if !path.is_empty() => format!("/{path}"),
Some((host, _)) => host.to_string(),
None => rest.to_string(),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -287,7 +280,7 @@ mod tests {
let r = &c.request; let r = &c.request;
assert_eq!(r.method, "GET"); assert_eq!(r.method, "GET");
assert_eq!(r.url, "https://api.github.com/search/issues"); assert_eq!(r.url, "https://api.github.com/search/issues");
assert_eq!(r.name, "/search/issues"); assert_eq!(r.name, "");
let params: Vec<(&str, &str)> = let params: Vec<(&str, &str)> =
r.url_parameters.iter().map(|p| (p.name.as_str(), p.value.as_str())).collect(); r.url_parameters.iter().map(|p| (p.name.as_str(), p.value.as_str())).collect();
assert_eq!(params, vec![("q", "is:open label:bug"), ("per_page", "5")]); assert_eq!(params, vec![("q", "is:open label:bug"), ("per_page", "5")]);
@@ -391,7 +384,7 @@ mod tests {
{ "enabled": true, "name": "note", "value": "hello world!" }, { "enabled": true, "name": "note", "value": "hello world!" },
]) ])
); );
assert_eq!(c.request.name, "/login"); assert_eq!(c.request.name, "", "unnamed, so Yaak shows its URL");
} }
#[test] #[test]
@@ -412,7 +405,7 @@ mod tests {
let c = flow_to_request(&f, &body("hello")); let c = flow_to_request(&f, &body("hello"));
assert_eq!(c.request.body_type.as_deref(), Some("other")); assert_eq!(c.request.body_type.as_deref(), Some("other"));
assert_eq!(c.request.body["text"], "hello"); assert_eq!(c.request.body["text"], "hello");
assert_eq!(c.request.name, "x.test"); assert_eq!(c.request.name, "");
} }
#[test] #[test]
+39 -3
View File
@@ -1,6 +1,7 @@
use include_dir::{Dir, include_dir}; use include_dir::{Dir, include_dir};
use r2d2::Pool; use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager; use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::functions::FunctionFlags;
use rusqlite::{OpenFlags, Transaction, TransactionBehavior}; use rusqlite::{OpenFlags, Transaction, TransactionBehavior};
use std::path::Path; use std::path::Path;
use std::time::Duration; use std::time::Duration;
@@ -76,10 +77,23 @@ pub fn open_blobs_read_only(path: &Path) -> Result<BlobManager> {
Ok(BlobManager::new(readers.clone(), readers)) Ok(BlobManager::new(readers.clone(), readers))
} }
/// `sidebar_path(url)`, the app sidebar's place for a URL, for filtering by its nodes.
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)?)),
)
}
fn read_only_pool(path: &Path) -> Result<SqlitePool> { fn read_only_pool(path: &Path) -> Result<SqlitePool> {
let manager = SqliteConnectionManager::file(path) let manager = SqliteConnectionManager::file(path)
.with_flags(OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX) .with_flags(OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX)
.with_init(|conn| conn.busy_timeout(Duration::from_millis(5000))); .with_init(|conn| {
conn.busy_timeout(Duration::from_millis(5000))?;
add_functions(conn)
});
Pool::builder() Pool::builder()
.max_size(2) .max_size(2)
.connection_timeout(Duration::from_secs(10)) .connection_timeout(Duration::from_secs(10))
@@ -91,7 +105,8 @@ fn file_pool(path: &Path, max_size: u32) -> Result<SqlitePool> {
let manager = SqliteConnectionManager::file(path).with_init(|conn| { let manager = SqliteConnectionManager::file(path).with_init(|conn| {
conn.pragma_update(None, "journal_mode", "WAL")?; conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?; conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.busy_timeout(Duration::from_millis(5000)) conn.busy_timeout(Duration::from_millis(5000))?;
add_functions(conn)
}); });
Pool::builder() Pool::builder()
.max_size(max_size) .max_size(max_size)
@@ -117,10 +132,14 @@ mod tests {
let path = dir.path().join("proxy.db"); let path = dir.path().join("proxy.db");
drop(ProxyQueryManager::new(&path).unwrap()); drop(ProxyQueryManager::new(&path).unwrap());
// As a build from before the saved request column left it. // As a build from before the saved request and client columns left it.
let conn = Connection::open(&path).unwrap(); let conn = Connection::open(&path).unwrap();
conn.execute_batch( conn.execute_batch(
"ALTER TABLE flows DROP COLUMN saved_request_id; "ALTER TABLE flows DROP COLUMN saved_request_id;
ALTER TABLE flows DROP COLUMN client_pid;
ALTER TABLE flows DROP COLUMN client_name;
ALTER TABLE flows DROP COLUMN client_bundle_id;
ALTER TABLE flows DROP COLUMN client_path;
INSERT INTO flows (id, url, method) VALUES ('fl_old', 'https://example.com', 'GET');", INSERT INTO flows (id, url, method) VALUES ('fl_old', 'https://example.com', 'GET');",
) )
.unwrap(); .unwrap();
@@ -130,6 +149,7 @@ mod tests {
let flows = db.with_conn(|c| c.find_all::<Flow>()).unwrap(); let flows = db.with_conn(|c| c.find_all::<Flow>()).unwrap();
assert_eq!(flows.len(), 1); assert_eq!(flows.len(), 1);
assert_eq!((flows[0].id.as_str(), flows[0].saved_request_id.as_deref()), ("fl_old", None)); assert_eq!((flows[0].id.as_str(), flows[0].saved_request_id.as_deref()), ("fl_old", None));
assert_eq!((flows[0].client_pid, flows[0].client_name.as_deref()), (None, None));
} }
#[test] #[test]
@@ -169,6 +189,22 @@ mod tests {
let flow_columns = columns(&conn, "flows"); let flow_columns = columns(&conn, "flows");
assert!(flow_columns.contains(&"response_truncated".to_string())); assert!(flow_columns.contains(&"response_truncated".to_string()));
for column in [
"client_pid",
"client_name",
"client_bundle_id",
"client_path",
] {
assert!(flow_columns.contains(&column.to_string()), "{column} in {flow_columns:?}");
}
let indexes: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'flows' AND sql IS NOT NULL")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.map(|i| i.unwrap())
.collect();
assert_eq!(indexes, vec!["idx_flows_started_at_id"]);
assert!(!flow_columns.iter().any(|c| c.contains("body")), "{flow_columns:?}"); assert!(!flow_columns.iter().any(|c| c.contains("body")), "{flow_columns:?}");
let journal_mode: String = conn.query_row("PRAGMA journal_mode", [], |r| r.get(0)).unwrap(); let journal_mode: String = conn.query_row("PRAGMA journal_mode", [], |r| r.get(0)).unwrap();
+397 -49
View File
@@ -3,6 +3,7 @@ use crate::db::ProxyQueryManager;
use crate::models::{Flow, FlowBody, FlowIden}; use crate::models::{Flow, FlowBody, FlowIden};
use base64::Engine; use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64; use base64::engine::general_purpose::STANDARD as BASE64;
use rusqlite::types::Value;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ts_rs::TS; use ts_rs::TS;
use yaak_database::UpsertModelInfo; use yaak_database::UpsertModelInfo;
@@ -13,9 +14,21 @@ use yaak_models::blob_manager::BlobManager;
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")] #[ts(export, export_to = "gen_rpc.ts")]
pub struct FlowFilter { pub struct 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`.
#[ts(optional)]
pub q: Option<String>,
/// Part of the host, any case: `stripe` matches `api.stripe.com`. /// Part of the host, any case: `stripe` matches `api.stripe.com`.
#[ts(optional)] #[ts(optional)]
pub host: Option<String>, 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.
#[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.
#[ts(optional)]
pub app: Option<Vec<String>>,
/// The method, any case. /// The method, any case.
#[ts(optional)] #[ts(optional)]
pub method: Option<String>, pub method: Option<String>,
@@ -36,14 +49,17 @@ pub struct FlowFilter {
} }
impl FlowFilter { impl FlowFilter {
/// The filter from a query string's `host`, `method`, `status`, `limit`, `after` and /// The filter from a query string's `q`, `host`, `under`, `app`, `method`, `status`,
/// `before`. /// `limit`, `after` and `before`. `under` and `app` may repeat.
pub fn from_query(query: &str) -> Result<Self, String> { pub fn from_query(query: &str) -> Result<Self, String> {
let mut filter = Self::default(); let mut filter = Self::default();
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
let value = Some(value.into_owned()).filter(|v| !v.is_empty()); let value = Some(value.into_owned()).filter(|v| !v.is_empty());
match key.as_ref() { match key.as_ref() {
"q" => filter.q = value,
"host" => filter.host = value, "host" => filter.host = value,
"under" => filter.under.get_or_insert_default().extend(value),
"app" => filter.app.get_or_insert_default().extend(value),
"method" => filter.method = value, "method" => filter.method = value,
"status" => filter.status = value, "status" => filter.status = value,
"after" => filter.after = value, "after" => filter.after = value,
@@ -62,8 +78,17 @@ impl FlowFilter {
pub fn to_query(&self) -> String { pub fn to_query(&self) -> String {
let mut query = url::form_urlencoded::Serializer::new(String::new()); let mut query = url::form_urlencoded::Serializer::new(String::new());
let limit = self.limit.map(|l| l.to_string()); let limit = self.limit.map(|l| l.to_string());
for (key, value) in [("q", &self.q), ("host", &self.host)] {
if let Some(value) = value {
query.append_pair(key, value);
}
}
for (key, values) in [("under", &self.under), ("app", &self.app)] {
for value in values.iter().flatten() {
query.append_pair(key, value);
}
}
for (key, value) in [ for (key, value) in [
("host", &self.host),
("method", &self.method), ("method", &self.method),
("status", &self.status), ("status", &self.status),
("limit", &limit), ("limit", &limit),
@@ -77,56 +102,175 @@ impl FlowFilter {
query.finish() query.finish()
} }
pub fn matches(&self, flow: &Flow) -> bool { /// Narrows more than `after`, `before` and `limit` do, so it covers fewer than all flows.
if let Some(host) = &self.host pub fn narrows(&self) -> bool {
&& !host_of(&flow.url).to_ascii_lowercase().contains(&host.to_ascii_lowercase()) let given = |v: &Option<String>| v.as_deref().is_some_and(|v| !v.trim().is_empty());
{ given(&self.q)
return false; || given(&self.host)
|| given(&self.method)
|| given(&self.status)
|| self.under.is_some()
|| self.app.is_some()
}
/// The SQL condition for every field but `after`, `before` and `limit`, and its parameters.
fn condition(&self) -> (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()) {
let term = term.to_lowercase();
sql.push_str(
" AND (instr(lower(url), ?) > 0 OR lower(method) = ? \
OR instr(lower(content_type), ?) > 0",
);
params.extend([
term.clone().into(),
term.clone().into(),
term.clone().into(),
]);
if let Some(status) = status_condition(&term, &mut params) {
sql.push_str(" OR ");
sql.push_str(&status);
}
sql.push(')');
} }
if let Some(method) = &self.method if let Some(host) = self.host.as_deref().filter(|h| !h.is_empty()) {
&& !flow.method.eq_ignore_ascii_case(method) sql.push_str(&format!(" AND instr(lower({HOST_SQL}), ?) > 0"));
{ params.push(host.to_lowercase().into());
return false; }
if let Some(under) = &self.under {
// The node itself, or anything below it, by the sidebar's rules.
let mut any = Vec::new();
for node in under {
let node = normalize_node(node);
any.push("(sidebar_path(url) = ? OR substr(sidebar_path(url), 1, ?) = ?)");
let below = format!("{node}/");
params.extend([
node.into(),
(below.chars().count() as i64).into(),
below.into(),
]);
}
let any = if any.is_empty() { "0".to_string() } else { any.join(" OR ") };
sql.push_str(&format!(" AND ({any})"));
}
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})"));
params.extend(apps.iter().map(|a| a.clone().into()));
}
if let Some(method) = self.method.as_deref().filter(|m| !m.is_empty()) {
sql.push_str(" AND lower(method) = ?");
params.push(method.to_lowercase().into());
} }
match self.status.as_deref().map(str::trim) { match self.status.as_deref().map(str::trim) {
None | Some("") => true, None | Some("") => {}
Some(s) if s.eq_ignore_ascii_case("error") => flow.error.is_some(), Some(s) if s.eq_ignore_ascii_case("error") => sql.push_str(" AND error IS NOT NULL"),
Some(s) => match (flow.status, s.to_ascii_lowercase().strip_suffix("xx")) { // A whole status or a class, not the start of one as a search term may be.
(Some(status), Some(class)) => class.parse() == Ok(status / 100), Some(s) if s.len() == 3 => match status_condition(&s.to_ascii_lowercase(), &mut params)
(Some(status), None) => s.parse() == Ok(status), {
(None, _) => false, Some(status) => {
sql.push_str(" AND ");
sql.push_str(&status);
}
None => sql.push_str(" AND 0"),
}, },
Some(_) => sql.push_str(" AND 0"),
} }
(sql, params)
} }
} }
/// `host[:port]` of a captured URL. /// The host and port of `url`, in SQL: what follows `://` up to the first `/`, `?` or `#`.
fn host_of(url: &str) -> &str { const HOST_SQL: &str = "(SELECT CASE WHEN instr(r, '/') > 0 THEN substr(r, 1, instr(r, '/') - 1) \
let rest = url.split_once("://").map_or(url, |(_, rest)| rest); ELSE r END FROM (SELECT replace(replace(CASE WHEN instr(url, '://') > 0 \
rest.split(['/', '?', '#']).next().unwrap_or_default() THEN substr(url, instr(url, '://') + 3) ELSE url END, '?', '/'), '#', '/') AS r))";
/// Where the app's 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. `sidebarPath` in the app's `lib/search.ts`
/// follows the same rules, and the sidebar's nodes are built from it.
pub fn sidebar_path(url: &str) -> String {
let Some((scheme, rest)) = url.split_once("://") else {
return url.to_string();
};
let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let (authority, rest) = rest.split_at(end);
let mut host = authority.to_ascii_lowercase();
let default_port = match scheme.to_ascii_lowercase().as_str() {
"http" | "ws" => Some(":80"),
"https" | "wss" => Some(":443"),
_ => None,
};
if let Some(port) = default_port
&& host.ends_with(port)
{
host.truncate(host.len() - port.len());
}
let path = rest.split(['?', '#']).next().unwrap_or_default();
let mut key = host;
for segment in path.split('/').filter(|s| !s.is_empty()) {
key.push('/');
key.push_str(segment);
}
key
} }
/// The flows `filter` covers, newest first: by start, then by id. Reads only as far into the /// A sidebar node as `sidebar_path` writes it, its host lowercased.
/// table as the limit needs, so a page costs the same however many flows there are. fn normalize_node(node: &str) -> String {
match node.split_once('/') {
Some((host, path)) => format!("{}/{path}", host.to_ascii_lowercase()),
None => node.to_ascii_lowercase(),
}
}
/// 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> {
if let Some(class) = term.strip_suffix("xx")
&& let Ok(class @ 1..=9) = class.parse::<i64>()
{
params.push(class.into());
return Some("status / 100 = ?".into());
}
if term.is_empty() || term.len() > 3 || !term.chars().all(|c| c.is_ascii_digit()) {
return None;
}
if term.len() == 3 {
params.push(term.parse::<i64>().ok()?.into());
return Some("status = ?".into());
}
params.push(format!("{term}%").into());
Some("CAST(status AS TEXT) LIKE ?".into())
}
/// The flows `filter` covers, newest first: by start, then by id. Reads the table in that order
/// through its index and stops at the limit, so a page of a common match costs the same however
/// many flows there are.
pub fn list(db: &ProxyQueryManager, filter: &FlowFilter) -> Result<Vec<Flow>, String> { pub fn list(db: &ProxyQueryManager, filter: &FlowFilter) -> Result<Vec<Flow>, String> {
db.with_conn(|c| list_in(c.conn().resolve(), filter)) db.with_conn(|c| list_in(c.conn().resolve(), filter))
} }
/// [`list`] and the count of every flow, read from one snapshot of the database, so the count /// [`list`] and the count of every flow, read from one snapshot of the database, so the count
/// takes in exactly the flows written before the page was read. /// takes in exactly the flows written before the page was read. With a filter that narrows,
pub fn page(db: &ProxyQueryManager, filter: &FlowFilter) -> Result<(Vec<Flow>, usize), String> { /// also the count of the flows it covers, whatever the limit.
pub fn page(
db: &ProxyQueryManager,
filter: &FlowFilter,
) -> Result<(Vec<Flow>, usize, Option<usize>), String> {
db.with_conn(|c| { db.with_conn(|c| {
let tx = c.conn().resolve().unchecked_transaction().map_err(|e| e.to_string())?; let tx = c.conn().resolve().unchecked_transaction().map_err(|e| e.to_string())?;
let flows = list_in(&tx, filter)?; let flows = list_in(&tx, filter)?;
let total = count_in(&tx)?; let total = count_in(&tx)?;
let matching = if filter.narrows() { Some(count_matching(&tx, filter)?) } else { None };
tx.commit().map_err(|e| e.to_string())?; tx.commit().map_err(|e| e.to_string())?;
Ok((flows, total)) Ok((flows, total, matching))
}) })
} }
fn list_in(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result<Vec<Flow>, String> { fn list_in(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result<Vec<Flow>, String> {
let mut sql = String::from("SELECT * FROM flows WHERE 1"); let (condition, mut params) = filter.condition();
let mut params: Vec<rusqlite::types::Value> = Vec::new(); let mut sql = format!("SELECT * FROM flows WHERE {condition}");
if let Some(id) = &filter.after { if let Some(id) = &filter.after {
// By rowid, which only grows as flows are written. // By rowid, which only grows as flows are written.
let rowid: i64 = conn let rowid: i64 = conn
@@ -136,7 +280,7 @@ fn list_in(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result<Vec<Flow>
params.push(rowid.into()); params.push(rowid.into());
} }
if let Some(id) = &filter.before { if let Some(id) = &filter.before {
let started_at: rusqlite::types::Value = conn let started_at: Value = conn
.query_row("SELECT started_at FROM flows WHERE id = ?", [id], |r| r.get(0)) .query_row("SELECT started_at FROM flows WHERE id = ?", [id], |r| r.get(0))
.map_err(|_| format!("No flow {id}"))?; .map_err(|_| format!("No flow {id}"))?;
sql.push_str(" AND (started_at, id) < (?, ?)"); sql.push_str(" AND (started_at, id) < (?, ?)");
@@ -144,21 +288,26 @@ fn list_in(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result<Vec<Flow>
params.push(id.clone().into()); params.push(id.clone().into());
} }
sql.push_str(" ORDER BY started_at DESC, id DESC"); sql.push_str(" ORDER BY started_at DESC, id DESC");
if let Some(limit) = filter.limit {
sql.push_str(" LIMIT ?");
params.push((limit.min(i64::MAX as usize) as i64).into());
}
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
let rows = stmt let rows = stmt
.query_map(rusqlite::params_from_iter(params), Flow::from_row) .query_map(rusqlite::params_from_iter(params), Flow::from_row)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let mut flows = Vec::new(); rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
for flow in rows { }
if flows.len() >= filter.limit.unwrap_or(usize::MAX) {
break; fn count_matching(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result<usize, String> {
} let (condition, params) = filter.condition();
let flow = flow.map_err(|e| e.to_string())?; conn.query_row(
if filter.matches(&flow) { &format!("SELECT COUNT(*) FROM flows WHERE {condition}"),
flows.push(flow); rusqlite::params_from_iter(params),
} |r| r.get::<_, i64>(0),
} )
Ok(flows) .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 /// Deletes every flow and their bodies, in a transaction in each database. The flows' deletion
@@ -288,26 +437,218 @@ mod tests {
} }
} }
/// A database of `flows`, started a second apart in the order given, and a function that
/// lists the urls a filter covers, newest first.
fn db_of(flows: Vec<Flow>) -> (tempfile::TempDir, ProxyQueryManager) {
use yaak_database::UpdateSource;
let dir = tempfile::tempdir().unwrap();
let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap();
for (i, flow) in flows.into_iter().enumerate() {
let started_at = chrono::DateTime::from_timestamp(i as i64, 0).unwrap().naive_utc();
let flow = Flow { id: format!("fl_{i:03}"), started_at, ..flow };
db.with_tx(|tx| tx.upsert(&flow, &UpdateSource::Background)).unwrap();
}
(dir, db)
}
fn urls(db: &ProxyQueryManager, filter: FlowFilter) -> Vec<String> {
list(db, &filter).unwrap().into_iter().map(|f| f.url).collect()
}
#[test] #[test]
fn filters() { fn filters() {
let ok = flow("https://api.stripe.com:8443/v1/charges?x=1", "POST", Some(200), None); let (_dir, db) = db_of(vec![
let missing = flow("http://example.com/missing", "GET", Some(404), None); flow("https://api.stripe.com:8443/v1/charges?x=1", "POST", Some(200), None),
let failed = flow("https://down.example.com/", "GET", None, Some("refused")); flow("http://example.com/missing", "GET", Some(404), None),
flow("https://down.example.com/", "GET", None, Some("refused")),
let by = |filter: FlowFilter| { ]);
[&ok, &missing, &failed].iter().filter(|f| filter.matches(f)).count() let by = |filter: FlowFilter| urls(&db, filter).len();
};
assert_eq!(by(FlowFilter::default()), 3); assert_eq!(by(FlowFilter::default()), 3);
assert_eq!(by(FlowFilter { host: Some("STRIPE".into()), ..Default::default() }), 1); assert_eq!(by(FlowFilter { host: Some("STRIPE".into()), ..Default::default() }), 1);
assert_eq!(by(FlowFilter { host: Some("example.com".into()), ..Default::default() }), 2); assert_eq!(by(FlowFilter { host: Some("example.com".into()), ..Default::default() }), 2);
assert_eq!(by(FlowFilter { host: Some("charges".into()), ..Default::default() }), 0); assert_eq!(by(FlowFilter { host: Some("charges".into()), ..Default::default() }), 0);
assert_eq!(by(FlowFilter { host: Some("8443".into()), ..Default::default() }), 1);
assert_eq!(by(FlowFilter { method: Some("get".into()), ..Default::default() }), 2); assert_eq!(by(FlowFilter { method: Some("get".into()), ..Default::default() }), 2);
assert_eq!(by(FlowFilter { status: Some("404".into()), ..Default::default() }), 1); assert_eq!(by(FlowFilter { status: Some("404".into()), ..Default::default() }), 1);
assert_eq!(by(FlowFilter { status: Some("2xx".into()), ..Default::default() }), 1); assert_eq!(by(FlowFilter { status: Some("2xx".into()), ..Default::default() }), 1);
assert_eq!(by(FlowFilter { status: Some("5XX".into()), ..Default::default() }), 0); assert_eq!(by(FlowFilter { status: Some("5XX".into()), ..Default::default() }), 0);
assert_eq!(by(FlowFilter { status: Some("40".into()), ..Default::default() }), 0);
assert_eq!(by(FlowFilter { status: Some("error".into()), ..Default::default() }), 1); assert_eq!(by(FlowFilter { status: Some("error".into()), ..Default::default() }), 1);
} }
#[test]
fn search_matches_url_method_status_and_content_type() {
let json = |url: &str, method: &str, status| Flow {
content_type: Some("application/json; charset=utf-8".into()),
..flow(url, method, Some(status), None)
};
let (_dir, db) = db_of(vec![
json("https://api.stripe.com/v1/charges?customer=cus_9", "POST", 200),
json("https://api.github.com/repos/yaak", "GET", 404),
Flow {
content_type: Some("image/png".into()),
..flow("https://images.ctfassets.net/logo.png", "GET", Some(200), None)
},
flow("http://localhost:3000/health", "HEAD", Some(503), None),
]);
let search = |q: &str| {
let filter = FlowFilter { q: Some(q.into()), ..Default::default() };
let mut found = urls(&db, filter);
found.sort();
found
};
let stripe = "https://api.stripe.com/v1/charges?customer=cus_9";
let github = "https://api.github.com/repos/yaak";
let logo = "https://images.ctfassets.net/logo.png";
let health = "http://localhost:3000/health";
// The host, the path and the query, any case.
assert_eq!(search("STRIPE"), vec![stripe]);
assert_eq!(search("/repos/"), vec![github]);
assert_eq!(search("cus_9"), vec![stripe]);
// The method, whole.
assert_eq!(search("head"), vec![health]);
assert_eq!(search("ead"), Vec::<&str>::new());
// A status, its start, or its class.
assert_eq!(search("404"), vec![github]);
assert_eq!(search("50"), vec![health]);
assert_eq!(search("2xx"), vec![stripe, logo]);
// The content type.
assert_eq!(search("png"), vec![logo]);
assert_eq!(search("json"), vec![github, stripe]);
// Every term must match, each anywhere.
assert_eq!(search("json get"), vec![github]);
assert_eq!(search(" json 2xx "), vec![stripe]);
assert_eq!(search("json 5xx"), Vec::<&str>::new());
// `%` and `_` are plain characters, not patterns.
assert_eq!(search("%"), Vec::<&str>::new());
assert_eq!(search("cus_"), vec![stripe]);
assert_eq!(search(""), vec![health, github, stripe, logo]);
}
#[test]
fn under_matches_a_host_or_a_path_as_the_sidebar_does() {
let (_dir, db) = db_of(vec![
flow("https://api.stripe.com/v1/charges", "GET", Some(200), None),
flow("https://api.stripe.com/v1/charges/ch_1?expand=x", "GET", Some(200), None),
flow("https://api.stripe.com/v1/chargesx", "GET", Some(200), None),
flow("https://api.stripe.com:8443/v1/charges", "GET", Some(200), None),
flow("http://api.stripe.com", "GET", Some(200), None),
flow("https://api.stripe.community/", "GET", Some(200), None),
flow("https://example.com/v1//users", "GET", Some(200), None),
flow("http://x.com:80/a", "GET", Some(200), None),
flow("https://x.com:443/a", "GET", Some(200), None),
]);
let under = |nodes: &[&str]| {
let nodes = nodes.iter().map(|n| n.to_string()).collect();
urls(&db, FlowFilter { under: Some(nodes), ..Default::default() }).len()
};
// A host on another port is a node of its own, as in the sidebar.
assert_eq!(under(&["api.stripe.com"]), 4);
assert_eq!(under(&["api.stripe.com:8443"]), 1);
assert_eq!(under(&["api.stripe.com/v1/charges"]), 2);
// Empty segments and default ports are left out, as the sidebar leaves them.
assert_eq!(under(&["example.com/v1/users"]), 1);
assert_eq!(under(&["x.com/a"]), 2);
assert_eq!(under(&["api.stripe.com/v1/charges", "example.com"]), 3);
assert_eq!(under(&[]), 0);
// The sidebar lowercases hosts; paths keep their case.
assert_eq!(under(&["API.stripe.com/v1/charges"]), 2);
assert_eq!(under(&["api.stripe.com/V1/charges"]), 0);
}
/// The cases `lib/search.test.ts` checks `sidebarPath` against, so the two agree.
#[test]
fn sidebar_path_rules() {
for (url, path) in [
("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"),
] {
assert_eq!(sidebar_path(url), path, "{url}");
}
}
#[test]
fn app_matches_a_bundle_id_or_a_path() {
let from = |bundle_id: Option<&str>, path: &str| Flow {
client_bundle_id: bundle_id.map(String::from),
client_path: Some(path.into()),
..flow("https://example.com/", "GET", Some(200), None)
};
let (_dir, db) = db_of(vec![
from(Some("com.google.Chrome"), "/Applications/Google Chrome.app"),
from(Some("com.google.Chrome"), "/Applications/Google Chrome.app"),
from(None, "/usr/bin/curl"),
flow("https://example.com/", "GET", Some(200), None),
]);
let app = |apps: &[&str]| {
let apps = apps.iter().map(|a| a.to_string()).collect();
urls(&db, FlowFilter { app: Some(apps), ..Default::default() }).len()
};
assert_eq!(app(&["com.google.Chrome"]), 2);
assert_eq!(app(&["/usr/bin/curl"]), 1);
assert_eq!(app(&["/Applications/Google Chrome.app"]), 0);
assert_eq!(app(&["com.google.Chrome", "/usr/bin/curl"]), 3);
}
#[test]
fn a_search_pages_back_and_counts_what_it_matches() {
// Every third flow is a POST, newest last.
let (_dir, db) = db_of(
(0..30)
.map(|i| {
let method = if i % 3 == 0 { "POST" } else { "GET" };
flow(&format!("https://api.example.com/{i}"), method, Some(200), None)
})
.collect(),
);
let search = |before: Option<String>| FlowFilter {
q: Some("post".into()),
before,
limit: Some(4),
..Default::default()
};
let (first, total, matching) = page(&db, &search(None)).unwrap();
let ids = |flows: &[Flow]| flows.iter().map(|f| f.id.clone()).collect::<Vec<_>>();
assert_eq!(ids(&first), vec!["fl_027", "fl_024", "fl_021", "fl_018"]);
assert_eq!((total, matching), (30, Some(10)));
let (second, _, _) = page(&db, &search(Some("fl_018".into()))).unwrap();
assert_eq!(ids(&second), vec!["fl_015", "fl_012", "fl_009", "fl_006"]);
let (last, _, _) = page(&db, &search(Some("fl_006".into()))).unwrap();
assert_eq!(ids(&last), vec!["fl_003", "fl_000"]);
// Without a narrowing filter there is nothing more to count.
let (_, _, matching) =
page(&db, &FlowFilter { limit: Some(1), ..Default::default() }).unwrap();
assert_eq!(matching, None);
}
#[test]
fn a_search_reads_the_newest_page_through_the_index() {
let (_dir, db) = db_of(vec![]);
let plan: Vec<String> = db.with_conn(|c| {
let conn = c.conn().resolve();
let (condition, params) =
FlowFilter { q: Some("stripe".into()), ..Default::default() }.condition();
let sql = format!(
"EXPLAIN QUERY PLAN SELECT * FROM flows WHERE {condition} \
ORDER BY started_at DESC, id DESC LIMIT 500"
);
let mut stmt = conn.prepare(&sql).unwrap();
stmt.query_map(rusqlite::params_from_iter(params), |r| r.get::<_, String>(3))
.unwrap()
.map(|r| r.unwrap())
.collect()
});
assert!(plan.iter().any(|p| p.contains("idx_flows_started_at_id")), "{plan:?}");
assert!(!plan.iter().any(|p| p.contains("TEMP B-TREE")), "{plan:?}");
}
#[test] #[test]
fn after_follows_flows_as_they_are_recorded() { fn after_follows_flows_as_they_are_recorded() {
use yaak_database::UpdateSource; use yaak_database::UpdateSource;
@@ -412,12 +753,19 @@ mod tests {
#[test] #[test]
fn query_round_trip() { fn query_round_trip() {
let filter = FlowFilter { let filter = FlowFilter {
q: Some("post json".into()),
host: Some("api.stripe.com".into()), host: Some("api.stripe.com".into()),
under: Some(vec!["api.stripe.com/v1".into(), "example.com".into()]),
app: Some(vec!["com.google.Chrome".into()]),
status: Some("4xx".into()), status: Some("4xx".into()),
limit: Some(20), limit: Some(20),
..Default::default() ..Default::default()
}; };
assert_eq!(filter.to_query(), "host=api.stripe.com&status=4xx&limit=20"); assert_eq!(
filter.to_query(),
"q=post+json&host=api.stripe.com&under=api.stripe.com%2Fv1&under=example.com\
&app=com.google.Chrome&status=4xx&limit=20"
);
assert_eq!(FlowFilter::from_query(&filter.to_query()).unwrap(), filter); assert_eq!(FlowFilter::from_query(&filter.to_query()).unwrap(), filter);
assert_eq!(FlowFilter::from_query("").unwrap(), FlowFilter::default()); assert_eq!(FlowFilter::from_query("").unwrap(), FlowFilter::default());
assert!(FlowFilter::from_query("limit=lots").is_err()); assert!(FlowFilter::from_query("limit=lots").is_err());
+588 -37
View File
@@ -1,4 +1,5 @@
pub mod actions; pub mod actions;
pub mod attribution;
pub mod bodies; pub mod bodies;
pub mod ca; pub mod ca;
pub mod client; pub mod client;
@@ -13,8 +14,11 @@ pub mod save;
pub mod settings; pub mod settings;
use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction}; use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction};
use crate::attribution::Attribution;
use crate::ca::CaStatus; use crate::ca::CaStatus;
use crate::control::{ControlFile, ControlInfo, Owner}; use crate::client::ControlClient;
use crate::control::{ControlFile, ControlInfo, ControlServer, Owner};
use crate::convert::DroppedHeader;
use crate::db::ProxyQueryManager; use crate::db::ProxyQueryManager;
use crate::macos::{SystemProxyBackend, SystemProxyStatus}; use crate::macos::{SystemProxyBackend, SystemProxyStatus};
use crate::models::{Flow, FlowBody, FlowIden, ModelPayload, ProxyHeader, find_header}; use crate::models::{Flow, FlowBody, FlowIden, ModelPayload, ProxyHeader, find_header};
@@ -26,10 +30,12 @@ use chrono::{DateTime, Utc};
use log::{debug, warn}; use log::{debug, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::io::Write;
use std::net::{SocketAddr, TcpStream}; use std::net::{SocketAddr, TcpStream};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, SystemTime};
use ts_rs::TS; use ts_rs::TS;
use yaak_database::{ModelChangeEvent, UpdateSource, UpsertModelInfo}; use yaak_database::{ModelChangeEvent, UpdateSource, UpsertModelInfo};
use yaak_models::blob_manager::{BlobManager, BodyChunkWriter}; use yaak_models::blob_manager::{BlobManager, BodyChunkWriter};
@@ -46,10 +52,19 @@ pub struct ProxyCtx {
owner: Owner, owner: Owner,
client_data_dir: PathBuf, client_data_dir: PathBuf,
control: Arc<Mutex<Option<ControlFile>>>, control: Arc<Mutex<Option<ControlFile>>>,
/// The control API this app serves while it owns the engine. Not the CLI daemon's, which
/// holds its own.
control_server: Arc<Mutex<Option<ControlServer>>>,
/// Held through each system proxy change, password dialog included, so they apply in turn. /// Held through each system proxy change, password dialog included, so they apply in turn.
system_proxy_turn: Arc<Mutex<()>>, system_proxy_turn: Arc<Mutex<()>>,
/// The port the engine last ran on, which the system proxy may still point at. /// The port the engine last ran on, which the system proxy may still point at.
last_port: Arc<Mutex<Option<u16>>>, last_port: Arc<Mutex<Option<u16>>>,
/// Flows are stored. While paused, traffic still passes through, untouched and unrecorded.
recording: Arc<Mutex<RecordingLog>>,
/// This Mac's web traffic is meant to point at the running engine.
system_proxy_on: Arc<AtomicBool>,
/// Why the engine last failed to start, until it starts.
start_error: Arc<Mutex<Option<String>>>,
pub db: ProxyQueryManager, pub db: ProxyQueryManager,
pub blobs: BlobManager, pub blobs: BlobManager,
pub events: RpcEventEmitter, pub events: RpcEventEmitter,
@@ -68,8 +83,12 @@ impl ProxyCtx {
owner: Owner::App, owner: Owner::App,
client_data_dir: save::client_data_dir(), client_data_dir: save::client_data_dir(),
control: Arc::new(Mutex::new(None)), control: Arc::new(Mutex::new(None)),
control_server: Arc::new(Mutex::new(None)),
system_proxy_turn: Arc::new(Mutex::new(())), system_proxy_turn: Arc::new(Mutex::new(())),
last_port: Arc::new(Mutex::new(None)), last_port: Arc::new(Mutex::new(None)),
recording: Arc::new(Mutex::new(RecordingLog::default())),
system_proxy_on: Arc::new(AtomicBool::new(false)),
start_error: Arc::new(Mutex::new(None)),
db, db,
blobs, blobs,
events, events,
@@ -115,8 +134,26 @@ impl ProxyCtx {
/// Starts the engine on `port`, or the one in `settings.json`. Does nothing if it is /// Starts the engine on `port`, or the one in `settings.json`. Does nothing if it is
/// running. Points the system proxy at it when the settings ask for that. Refused while /// running. Points the system proxy at it when the settings ask for that. Refused while
/// another process owns the engine, so there are never two. /// another process owns the engine, so there are never two. A failure is kept until the
/// next start, for the app to show.
pub fn start_on(&self, port: Option<u16>) -> Result<(), String> { pub fn start_on(&self, port: Option<u16>) -> Result<(), String> {
let started = self.start_engine(port);
if let Ok(mut start_error) = self.start_error.lock() {
*start_error = started.as_ref().err().cloned();
}
if started.is_err() {
self.events
.emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Stopped });
}
started
}
/// Why the engine last failed to start, if it has not started since.
pub fn start_error(&self) -> Option<String> {
self.start_error.lock().ok()?.clone()
}
fn start_engine(&self, port: Option<u16>) -> Result<(), String> {
if let Some(owner) = control::live_owner(&self.data_dir) if let Some(owner) = control::live_owner(&self.data_dir)
&& owner.pid != std::process::id() && owner.pid != std::process::id()
{ {
@@ -132,13 +169,30 @@ impl ProxyCtx {
.map_err(|e| format!("Failed to load proxy CA: {e}"))?; .map_err(|e| format!("Failed to load proxy CA: {e}"))?;
let mut config = ProxyConfig::new(port.unwrap_or(settings.port), Arc::new(ca)); let mut config = ProxyConfig::new(port.unwrap_or(settings.port), Arc::new(ca));
config.capture_body_limit = settings.capture_body_limit; config.capture_body_limit = settings.capture_body_limit;
// Filled once the engine has bound, since the lookup matches sockets by its port.
let attribution_slot: Arc<OnceLock<Attribution>> = Arc::new(OnceLock::new());
let slot = attribution_slot.clone();
config.on_accept = Some(Arc::new(move |connection, client_addr| {
// A client that is answered quickly may exit soon after, so it is found before
// anything is read from it: a few milliseconds once per connection at most.
if let Some(attribution) = slot.get() {
attribution.begin(connection, client_addr);
attribution.get(connection, ACCEPT_ATTRIBUTION_WAIT);
}
}));
let mut proxy_handle = yaak_proxy::start_proxy(config)?; let mut proxy_handle = yaak_proxy::start_proxy(config)?;
let attribution = Attribution::new(proxy_handle.port);
let _ = attribution_slot.set(attribution.clone());
if let Some(event_rx) = proxy_handle.take_event_rx() { if let Some(event_rx) = proxy_handle.take_event_rx() {
let db = self.db.clone(); let sink = FlowSink {
let blobs = self.blobs.clone(); db: self.db.clone(),
let events = self.events.clone(); blobs: self.blobs.clone(),
std::thread::spawn(move || run_event_loop(event_rx, db, blobs, events)); events: self.events.clone(),
attribution,
recording: self.recording.clone(),
};
std::thread::spawn(move || run_event_loop(event_rx, sink));
} }
let bound = proxy_handle.port; let bound = proxy_handle.port;
@@ -148,13 +202,18 @@ impl ProxyCtx {
if let Ok(mut last_port) = self.last_port.lock() { if let Ok(mut last_port) = self.last_port.lock() {
*last_port = Some(bound); *last_port = Some(bound);
} }
if settings.system_proxy { self.system_proxy_on.store(settings.system_proxy_at_launch, Ordering::SeqCst);
if settings.system_proxy_at_launch {
// The password dialog can sit open for a while; the engine is up regardless. // The password dialog can sit open for a while; the engine is up regardless.
let ctx = self.clone(); let ctx = self.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
if let Err(e) = ctx.change_system_proxy(true, bound, true) { if let Err(e) = ctx.change_system_proxy(true, bound, true) {
warn!("Failed to turn on the system proxy: {e}"); warn!("Failed to turn on the system proxy: {e}");
ctx.system_proxy_on.store(false, Ordering::SeqCst);
} }
// So the app reads the system proxy again once the dialog is answered.
ctx.events
.emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Running });
}); });
} }
self.events.emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Running }); self.events.emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Running });
@@ -163,6 +222,7 @@ impl ProxyCtx {
/// Stops the engine and turns off a system proxy that points at it. /// Stops the engine and turns off a system proxy that points at it.
pub fn stop(&self) -> Result<(), String> { pub fn stop(&self) -> Result<(), String> {
self.system_proxy_on.store(false, Ordering::SeqCst);
let port = self.take_handle()?; let port = self.take_handle()?;
if let Some(port) = port { if let Some(port) = port {
let ctx = self.clone(); let ctx = self.clone();
@@ -218,6 +278,83 @@ impl ProxyCtx {
self.control.lock().ok()?.as_ref().map(|c| c.info.clone()) self.control.lock().ok()?.as_ref().map(|c| c.info.clone())
} }
/// Claims the engine for this app and serves the control API, unless it already does.
/// Fails while another live owner, the CLI daemon, has it. Resets a system proxy a crashed
/// owner left pointing at its port.
pub fn serve_control_api(&self) -> Result<(), String> {
let mut slot = self.control_server.lock().map_err(|_| "lock poisoned".to_string())?;
if slot.is_some() {
return Ok(());
}
// Read first: a crashed owner's file names the port its system proxy may still point at.
let previous = control::read(&self.data_dir);
*slot = Some(ControlServer::start(self.clone())?);
drop(slot);
let mut ports: Vec<u16> = previous.and_then(|p| p.proxy_port).into_iter().collect();
ports.extend(self.settings().ok().map(|s| s.port));
let ctx = self.clone();
std::thread::spawn(move || {
if let Err(e) = ctx.reset_stale_system_proxy(&ports) {
warn!("Failed to reset a stale system proxy: {e}");
}
});
Ok(())
}
/// Stops serving the control API and gives up the claim on the engine.
pub fn stop_control_api(&self) {
let server = self.control_server.lock().ok().and_then(|mut s| s.take());
drop(server);
}
/// Another live owner of the engine, such as the CLI daemon.
pub fn other_owner(&self) -> Option<ControlInfo> {
let ours = self.control_info().map(|c| c.token);
control::live_owner(&self.data_dir).filter(|o| Some(&o.token) != ours.as_ref())
}
/// Stops the engine in the process that owns it, waits for that process to give it up,
/// then claims it here and starts it. Fails, leaving things as they are, when the owner
/// does not let go.
pub async fn take_over(&self) -> Result<(), String> {
if let Some(owner) = self.other_owner() {
// Stopped through the control API, another app halts its engine but keeps the claim,
// so only the CLI daemon, which exits on it, can be taken over.
if owner.owner == Owner::App {
return Err(ANOTHER_APP_OWNS.to_string());
}
ControlClient::new(owner.clone()).stop().await?;
let ctx = self.clone();
let released = tokio::task::spawn_blocking(move || {
let deadline = std::time::Instant::now() + TAKE_OVER_WAIT;
while std::time::Instant::now() < deadline {
if ctx.other_owner().is_none() {
return true;
}
std::thread::sleep(Duration::from_millis(100));
}
false
})
.await
.map_err(|e| e.to_string())?;
if !released {
return Err(format!(
"The proxy in {} (pid {}) stopped but did not let go of it",
control::owner_name(owner.owner),
owner.pid
));
}
}
let ctx = self.clone();
tokio::task::spawn_blocking(move || {
ctx.serve_control_api()?;
ctx.start()
})
.await
.map_err(|e| e.to_string())?
}
fn set_control_proxy_port(&self, port: Option<u16>) { fn set_control_proxy_port(&self, port: Option<u16>) {
if let Ok(mut control) = self.control.lock() if let Ok(mut control) = self.control.lock()
&& let Some(control) = control.as_mut() && let Some(control) = control.as_mut()
@@ -240,23 +377,59 @@ impl ProxyCtx {
macos::status(port, self.system_proxy_backend()) macos::status(port, self.system_proxy_backend())
} }
/// Turns the system proxy on or off and remembers the choice. Turned on while the engine /// Points this Mac's web traffic at the running engine, or stops, at once, through the
/// is stopped, it waits for the engine to start, so the Mac is never pointed at nothing. /// password dialog. Not saved: the `systemProxyAtLaunch` setting decides whether it turns
/// Blocks, through the password dialog. /// on as the engine starts. Blocks.
pub fn set_system_proxy(&self, enabled: bool) -> Result<SystemProxyStatus, String> { pub fn set_system_proxy(&self, enabled: bool) -> Result<SystemProxyStatus, String> {
let mut settings = self.settings()?; let Some(port) = self.port() else {
let previous = settings.system_proxy; if enabled {
settings.system_proxy = enabled; return Err("The proxy is not running, so there is nothing to route to".into());
settings.save(&self.settings_path())?; }
let port = self.port().unwrap_or(settings.port); // Clears one left pointing at the configured port; asks for nothing otherwise.
self.change_system_proxy(false, self.settings()?.port, false)?;
return self.system_proxy_status();
};
let previous = self.system_proxy_on.swap(enabled, Ordering::SeqCst);
if let Err(e) = self.change_system_proxy(enabled, port, enabled) { if let Err(e) = self.change_system_proxy(enabled, port, enabled) {
settings.system_proxy = previous; self.system_proxy_on.store(previous, Ordering::SeqCst);
let _ = settings.save(&self.settings_path());
return Err(e); return Err(e);
} }
self.system_proxy_status() self.system_proxy_status()
} }
/// Whether the system proxy turns on as the engine starts, saved in `settings.json`.
pub fn set_system_proxy_at_launch(&self, enabled: bool) -> Result<(), String> {
let mut settings = self.settings()?;
settings.system_proxy_at_launch = enabled;
settings.save(&self.settings_path())
}
/// Intercepts again the hosts passed through after clients rejected the CA, as they no
/// longer will once it is trusted.
pub fn forget_passthrough(&self) {
if let Ok(handle) = self.handle.lock()
&& let Some(handle) = handle.as_ref()
{
handle.forget_passthrough();
}
}
/// Flows are being stored.
pub fn recording(&self) -> bool {
self.recording.lock().map(|r| r.is_on()).unwrap_or(true)
}
/// Resumes or pauses storing flows. Paused, traffic still passes through the engine, but no
/// new flow is stored; one already under way is finished.
pub fn set_recording(&self, recording: bool) {
let changed = self.recording.lock().map(|mut r| r.set(recording)).unwrap_or(false);
if changed {
let state =
if self.port().is_some() { ProxyState::Running } else { ProxyState::Stopped };
self.events.emit("proxy_state_changed", &ProxyStatePayload { state });
}
}
/// Points the system proxy at `port`, or turns it off there, once the changes before it /// Points the system proxy at `port`, or turns it off there, once the changes before it
/// are done. With `follow_engine`, the change is dropped if the engine has moved on by its /// are done. With `follow_engine`, the change is dropped if the engine has moved on by its
/// turn: an enable once the engine is off `port` or the setting is off, a disable once the /// turn: an enable once the engine is off `port` or the setting is off, a disable once the
@@ -271,7 +444,8 @@ impl ProxyCtx {
let _turn = self.system_proxy_turn.lock().map_err(|_| "lock poisoned".to_string())?; let _turn = self.system_proxy_turn.lock().map_err(|_| "lock poisoned".to_string())?;
if follow_engine { if follow_engine {
let on_port = self.port() == Some(port); let on_port = self.port() == Some(port);
let current = if enabled { on_port && self.settings()?.system_proxy } else { !on_port }; let wanted = self.system_proxy_on.load(Ordering::SeqCst);
let current = if enabled { on_port && wanted } else { !on_port };
if !current { if !current {
return Ok(()); return Ok(());
} }
@@ -302,6 +476,11 @@ impl ProxyCtx {
Ok(()) Ok(())
} }
/// Yaak has run on this machine, so there is a database to save into.
pub fn client_exists(&self) -> bool {
ClientDb::exists(&self.client_data_dir)
}
/// Yaak's workspaces and their folders, to save a flow into. /// Yaak's workspaces and their folders, to save a flow into.
pub fn workspaces(&self) -> Result<Vec<WorkspaceSummary>, String> { pub fn workspaces(&self) -> Result<Vec<WorkspaceSummary>, String> {
ClientDb::open(&self.client_data_dir)?.workspaces() ClientDb::open(&self.client_data_dir)?.workspaces()
@@ -354,6 +533,52 @@ fn is_listening(port: u16) -> bool {
TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok()
} }
// -- Recording --
/// Changes to recording kept, so a request whose events are read late is still judged by when
/// it started.
const RECORDING_CHANGES: usize = 64;
/// Whether flows are stored, and when that changed.
struct RecordingLog {
/// Oldest first, starting from recording since the beginning.
changes: std::collections::VecDeque<(SystemTime, bool)>,
}
impl Default for RecordingLog {
fn default() -> Self {
Self { changes: [(SystemTime::UNIX_EPOCH, true)].into() }
}
}
impl RecordingLog {
fn is_on(&self) -> bool {
self.changes.back().is_none_or(|(_, on)| *on)
}
/// Returns whether it changed.
fn set(&mut self, on: bool) -> bool {
if self.is_on() == on {
return false;
}
self.changes.push_back((SystemTime::now(), on));
while self.changes.len() > RECORDING_CHANGES {
self.changes.pop_front();
}
true
}
/// Whether flows were stored at `time`.
fn was_on_at(&self, time: SystemTime) -> bool {
self.changes
.iter()
.rev()
.find(|(at, _)| *at <= time)
.or(self.changes.front())
.is_none_or(|(_, on)| *on)
}
}
// -- Proxy state -- // -- Proxy state --
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
@@ -391,6 +616,9 @@ pub struct ListFlowsResponse {
/// Flows in the database, whatever the filter. /// Flows in the database, whatever the filter.
#[ts(type = "number")] #[ts(type = "number")]
pub total: usize, pub total: usize,
/// Flows the filter covers, whatever the limit, when it narrows to fewer than all of them.
#[ts(type = "number | null")]
pub matching: Option<usize>,
} }
#[derive(Deserialize, TS)] #[derive(Deserialize, TS)]
@@ -412,6 +640,130 @@ pub struct GetProxyStateResponse {
pub state: ProxyState, pub state: ProxyState,
/// The port the engine is bound to, while it runs. /// The port the engine is bound to, while it runs.
pub port: Option<u16>, pub port: Option<u16>,
/// The port the engine starts on.
pub configured_port: u16,
/// Flows are stored. Paused, traffic still passes through, unrecorded.
pub recording: bool,
/// The system proxy turns on as the engine starts.
pub system_proxy_at_launch: bool,
/// Why the engine last failed to start, until it starts.
pub start_error: Option<String>,
/// 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.
pub other_owner: Option<OtherOwner>,
}
#[derive(Serialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
#[serde(rename_all = "camelCase")]
pub struct OtherOwner {
pub owner: Owner,
pub pid: u32,
pub started_at: DateTime<Utc>,
/// The engine's port while it runs.
pub proxy_port: Option<u16>,
}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct TakeOverRequest {}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct SetRecordingRequest {
pub enabled: bool,
}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct SetSystemProxyAtLaunchRequest {
pub enabled: bool,
}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct AppIconRequest {
/// A flow's `clientPath`.
pub path: String,
}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct ListWorkspacesRequest {}
#[derive(Serialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
#[serde(rename_all = "camelCase")]
pub struct ListWorkspacesResponse {
/// Yaak has run on this machine. Without it there is nothing to save into.
pub yaak_found: bool,
pub workspaces: Vec<WorkspaceSummary>,
}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
#[serde(rename_all = "camelCase")]
pub struct PreviewSaveRequest {
pub flow_id: String,
}
/// What saving a flow as a Yaak request writes, for the save dialog.
#[derive(Serialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
#[serde(rename_all = "camelCase")]
pub struct SavePreview {
pub method: String,
pub url: String,
/// The URL as saved, its query split out into parameters, which Yaak shows for an unnamed
/// request.
pub request_url: String,
/// The saved body type, which Yaak shows for an unnamed GraphQL request with no URL.
pub body_type: Option<String>,
/// Headers Yaak sets itself or that only meant something to the proxy.
pub dropped_headers: Vec<DroppedHeader>,
/// Headers saved as captured, Authorization among them.
#[ts(type = "number")]
pub kept_headers: usize,
/// The body as it is saved: "JSON", "form URL-encoded with 7 fields". None without a body.
pub body: Option<String>,
/// What does not carry over exactly, one sentence each.
pub warnings: Vec<String>,
/// An Authorization header is saved in plain text.
pub authorization: bool,
}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
#[serde(rename_all = "camelCase")]
pub struct SaveFlowToYaakRequest {
pub flow_id: String,
pub workspace_id: String,
#[ts(optional)]
pub folder_id: Option<String>,
/// None or blank leaves the request unnamed, and Yaak shows its URL.
#[ts(optional)]
pub name: Option<String>,
}
#[derive(Serialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
#[serde(rename_all = "camelCase")]
pub struct SavedFlow {
pub request_id: String,
pub name: String,
}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
#[serde(rename_all = "camelCase")]
pub struct ExportHarRequest {
/// The file to write.
pub path: String,
/// These flows, or else every flow the filter covers.
#[ts(optional)]
pub ids: Option<Vec<String>>,
#[ts(optional)]
pub filter: Option<flows::FlowFilter>,
} }
#[derive(Deserialize, TS)] #[derive(Deserialize, TS)]
@@ -492,8 +844,7 @@ pub struct SystemProxySetRequest {
fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool, RpcError> { fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool, RpcError> {
match invocation { match invocation {
ActionInvocation::Global { action } => match action { ActionInvocation::Global { action } => match action {
GlobalAction::ProxyStart => ctx.start().map_err(|message| RpcError { message })?, GlobalAction::RecordingToggle => ctx.set_recording(!ctx.recording()),
GlobalAction::ProxyStop => ctx.stop().map_err(|message| RpcError { message })?,
GlobalAction::FlowsClear => { GlobalAction::FlowsClear => {
ctx.clear_flows().map_err(|message| RpcError { message })?; ctx.clear_flows().map_err(|message| RpcError { message })?;
} }
@@ -502,13 +853,137 @@ fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool,
Ok(true) Ok(true)
} }
fn get_proxy_state( async fn get_proxy_state(
ctx: &ProxyCtx, ctx: ProxyCtx,
_req: GetProxyStateRequest, _req: GetProxyStateRequest,
) -> Result<GetProxyStateResponse, RpcError> { ) -> Result<GetProxyStateResponse, RpcError> {
let port = ctx.port(); // The owner check takes a file lock and a connection, so off the async threads.
let state = if port.is_some() { ProxyState::Running } else { ProxyState::Stopped }; blocking(move || {
Ok(GetProxyStateResponse { state, port }) let port = ctx.port();
let state = if port.is_some() { ProxyState::Running } else { ProxyState::Stopped };
let settings = ctx.settings()?;
let other_owner = ctx.other_owner().map(|o| OtherOwner {
owner: o.owner,
pid: o.pid,
started_at: o.started_at,
proxy_port: o.proxy_port,
});
Ok(GetProxyStateResponse {
state,
port,
configured_port: settings.port,
recording: ctx.recording(),
system_proxy_at_launch: settings.system_proxy_at_launch,
start_error: ctx.start_error(),
other_owner,
})
})
.await
}
fn set_recording(ctx: &ProxyCtx, req: SetRecordingRequest) -> Result<bool, RpcError> {
ctx.set_recording(req.enabled);
Ok(ctx.recording())
}
fn set_system_proxy_at_launch(
ctx: &ProxyCtx,
req: SetSystemProxyAtLaunchRequest,
) -> Result<bool, RpcError> {
ctx.set_system_proxy_at_launch(req.enabled).map_err(rpc_error)?;
Ok(req.enabled)
}
async fn take_over(ctx: ProxyCtx, _req: TakeOverRequest) -> Result<bool, RpcError> {
ctx.take_over().await.map_err(rpc_error)?;
Ok(true)
}
async fn app_icon(_ctx: ProxyCtx, req: AppIconRequest) -> Result<Option<String>, RpcError> {
let png = blocking(move || attribution::app_icon_png(&req.path)).await?;
Ok(png.map(|png| BASE64.encode(png)))
}
async fn list_workspaces(
ctx: ProxyCtx,
_req: ListWorkspacesRequest,
) -> Result<ListWorkspacesResponse, RpcError> {
blocking(move || {
if !ctx.client_exists() {
return Ok(ListWorkspacesResponse { yaak_found: false, workspaces: Vec::new() });
}
Ok(ListWorkspacesResponse { yaak_found: true, workspaces: ctx.workspaces()? })
})
.await
}
async fn preview_save(ctx: ProxyCtx, req: PreviewSaveRequest) -> Result<SavePreview, RpcError> {
let flow = find_flow(&ctx, &req.flow_id)?;
let limit = settings(&ctx)?.capture_body_limit;
let body =
bodies::read_body(&ctx.blobs, &flow, FlowBody::Request, limit).await.map_err(rpc_error)?;
let conversion = convert::flow_to_request(&flow, &body);
let request = conversion.request;
let body = request.body_type.as_deref().map(|body_type| match body_type {
"application/json" => "JSON".to_string(),
"text/xml" => "XML".to_string(),
"graphql" => "GraphQL".to_string(),
"application/x-www-form-urlencoded" => {
let fields = request.body.get("form").and_then(|f| f.as_array()).map_or(0, Vec::len);
match fields {
1 => "form URL-encoded with 1 field".to_string(),
n => format!("form URL-encoded with {n} fields"),
}
}
_ => "text".to_string(),
});
let authorization =
request.headers.iter().any(|h| h.name.eq_ignore_ascii_case("authorization"));
Ok(SavePreview {
method: flow.method.clone(),
url: flow.url.clone(),
request_url: request.url.clone(),
body_type: request.body_type.clone(),
dropped_headers: conversion.dropped_headers,
kept_headers: request.headers.len(),
body,
warnings: conversion.warnings,
authorization,
})
}
async fn save_flow(ctx: ProxyCtx, req: SaveFlowToYaakRequest) -> Result<SavedFlow, RpcError> {
let target = SaveFlowRequest {
workspace_id: req.workspace_id,
folder_id: req.folder_id,
name: req.name,
};
let saved = ctx.save_flow(&req.flow_id, &target).await.map_err(rpc_error)?;
Ok(SavedFlow { request_id: saved.request.id, name: saved.request.name })
}
async fn export_har(ctx: ProxyCtx, req: ExportHarRequest) -> Result<usize, RpcError> {
let flows = match req.ids {
Some(ids) => ids.iter().map(|id| flows::get(&ctx.db, id)).collect::<Result<Vec<_>, _>>(),
None => {
let filter = flows::FlowFilter { limit: None, ..req.filter.unwrap_or_default() };
flows::list(&ctx.db, &filter)
}
}
.map_err(rpc_error)?;
let limit = settings(&ctx)?.capture_body_limit;
let har = har::export(&ctx.blobs, &flows, limit).await.map_err(rpc_error)?;
let path = PathBuf::from(req.path);
blocking(move || {
let file = std::fs::File::create(&path)
.map_err(|e| format!("Failed to create {}: {e}", path.display()))?;
let mut out = std::io::BufWriter::new(file);
serde_json::to_writer_pretty(&mut out, &har)
.map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
out.flush().map_err(|e| format!("Failed to write {}: {e}", path.display()))
})
.await?;
Ok(flows.len())
} }
fn format_body(_ctx: &ProxyCtx, req: FormatBodyRequest) -> Result<String, RpcError> { fn format_body(_ctx: &ProxyCtx, req: FormatBodyRequest) -> Result<String, RpcError> {
@@ -573,6 +1048,7 @@ async fn ca_trust(ctx: ProxyCtx, _req: CaTrustRequest) -> Result<CaStatus, RpcEr
let ca = CaStore::load_or_create(&ctx.ca_dir()) let ca = CaStore::load_or_create(&ctx.ca_dir())
.map_err(|e| rpc_error(format!("Failed to load proxy CA: {e}")))?; .map_err(|e| rpc_error(format!("Failed to load proxy CA: {e}")))?;
ca::trust(ca.cert_path()).await.map_err(rpc_error)?; ca::trust(ca.cert_path()).await.map_err(rpc_error)?;
ctx.forget_passthrough();
ca::status(ca.cert_path()).await.map_err(rpc_error) ca::status(ca.cert_path()).await.map_err(rpc_error)
} }
@@ -616,8 +1092,8 @@ fn list_actions(
} }
fn list_flows(ctx: &ProxyCtx, req: flows::FlowFilter) -> Result<ListFlowsResponse, RpcError> { fn list_flows(ctx: &ProxyCtx, req: flows::FlowFilter) -> Result<ListFlowsResponse, RpcError> {
let (flows, total) = flows::page(&ctx.db, &req).map_err(rpc_error)?; let (flows, total, matching) = flows::page(&ctx.db, &req).map_err(rpc_error)?;
Ok(ListFlowsResponse { flows, total }) Ok(ListFlowsResponse { flows, total, matching })
} }
async fn clear_flows(ctx: ProxyCtx, _req: ClearFlowsRequest) -> Result<usize, RpcError> { async fn clear_flows(ctx: ProxyCtx, _req: ClearFlowsRequest) -> Result<usize, RpcError> {
@@ -626,25 +1102,62 @@ async fn clear_flows(ctx: ProxyCtx, _req: ClearFlowsRequest) -> Result<usize, Rp
// -- Event loop -- // -- Event loop --
/// Why the engine cannot be taken over from another app.
pub const ANOTHER_APP_OWNS: &str =
"Another Yaak Proxy window owns the engine. Quit it to use this one.";
/// How long taking over waits for the owner to let go of the engine.
const TAKE_OVER_WAIT: Duration = Duration::from_secs(10);
/// The longest a new client connection waits for its app to be found before it is served.
const ACCEPT_ATTRIBUTION_WAIT: Duration = Duration::from_millis(20);
/// The longest a flow's end waits for its app to be found, which almost always it already is:
/// the lookup starts with the request and takes a few milliseconds.
const ATTRIBUTION_WAIT: Duration = Duration::from_millis(100);
/// A flow between its `RequestStart` and its end, with its bodies being written as they /// A flow between its `RequestStart` and its end, with its bodies being written as they
/// stream. /// stream.
struct InFlight { struct InFlight {
flow: Flow, flow: Flow,
/// The client connection it came on.
connection: u64,
request_body: Option<BodyChunkWriter>, request_body: Option<BodyChunkWriter>,
response_body: Option<BodyChunkWriter>, response_body: Option<BodyChunkWriter>,
} }
fn run_event_loop( /// Where the event loop stores flows, and whether it does.
rx: std::sync::mpsc::Receiver<ProxyEvent>, struct FlowSink {
db: ProxyQueryManager, db: ProxyQueryManager,
blobs: BlobManager, blobs: BlobManager,
events: RpcEventEmitter, events: RpcEventEmitter,
) { attribution: Attribution,
recording: Arc<Mutex<RecordingLog>>,
}
fn run_event_loop(rx: std::sync::mpsc::Receiver<ProxyEvent>, sink: FlowSink) {
let FlowSink { db, blobs, events, attribution, recording } = sink;
let mut in_flight: HashMap<u64, InFlight> = HashMap::new(); let mut in_flight: HashMap<u64, InFlight> = HashMap::new();
while let Ok(event) = rx.recv() { while let Ok(event) = rx.recv() {
match event { match event {
ProxyEvent::RequestStart { id, started_at, method, url, http_version } => { ProxyEvent::RequestStart {
id,
started_at,
method,
url,
http_version,
connection,
client_addr,
} => {
// Paused when it started: it passes through, and none of its events are kept.
// Judged by its start rather than now, as the loop can run behind.
if !recording.lock().map(|r| r.was_on_at(started_at)).unwrap_or(true) {
continue;
}
// Begun as the connection was accepted; this covers one accepted before the
// engine's port was known.
attribution.begin(connection, client_addr);
let flow_id = Flow::generate_id(); let flow_id = Flow::generate_id();
let request_body = Flow::body_id(&flow_id, FlowBody::Request); let request_body = Flow::body_id(&flow_id, FlowBody::Request);
let response_body = Flow::body_id(&flow_id, FlowBody::Response); let response_body = Flow::body_id(&flow_id, FlowBody::Response);
@@ -660,6 +1173,7 @@ fn run_event_loop(
id, id,
InFlight { InFlight {
flow, flow,
connection,
request_body: Some(BodyChunkWriter::new(blobs.clone(), request_body)), request_body: Some(BodyChunkWriter::new(blobs.clone(), request_body)),
response_body: Some(BodyChunkWriter::new(blobs.clone(), response_body)), response_body: Some(BodyChunkWriter::new(blobs.clone(), response_body)),
}, },
@@ -710,14 +1224,14 @@ fn run_event_loop(
f.flow.response_truncated = truncated; f.flow.response_truncated = truncated;
f.flow.elapsed_ms = Some(clamp_ms(elapsed_ms)); f.flow.elapsed_ms = Some(clamp_ms(elapsed_ms));
f.flow.error = error; f.flow.error = error;
finish_flow(&db, &events, f); finish_flow(&db, &events, &attribution, f);
} }
} }
ProxyEvent::Error { id, error, elapsed_ms } => { ProxyEvent::Error { id, error, elapsed_ms } => {
if let Some(mut f) = in_flight.remove(&id) { if let Some(mut f) = in_flight.remove(&id) {
f.flow.elapsed_ms = Some(clamp_ms(elapsed_ms)); f.flow.elapsed_ms = Some(clamp_ms(elapsed_ms));
f.flow.error = Some(error); f.flow.error = Some(error);
finish_flow(&db, &events, f); finish_flow(&db, &events, &attribution, f);
} }
} }
} }
@@ -749,12 +1263,23 @@ fn finish_body(writer: Option<BodyChunkWriter>) {
} }
} }
fn finish_flow(db: &ProxyQueryManager, events: &RpcEventEmitter, f: InFlight) { fn finish_flow(
db: &ProxyQueryManager,
events: &RpcEventEmitter,
attribution: &Attribution,
f: InFlight,
) {
finish_body(f.request_body); finish_body(f.request_body);
finish_body(f.response_body); finish_body(f.response_body);
let mut flow = f.flow; let mut flow = f.flow;
flow.content_type = find_header(&flow.response_headers, "content-type").map(String::from); flow.content_type = find_header(&flow.response_headers, "content-type").map(String::from);
if let Some(app) = attribution.get(f.connection, ATTRIBUTION_WAIT) {
flow.client_pid = Some(app.pid);
flow.client_name = Some(app.name);
flow.client_bundle_id = app.bundle_id;
flow.client_path = Some(app.path);
}
let written = db.with_tx(|ctx| { let written = db.with_tx(|ctx| {
let (saved, created) = ctx.upsert(&flow, &UpdateSource::Background)?; let (saved, created) = ctx.upsert(&flow, &UpdateSource::Background)?;
// Sent while this holds the writer, before the commit, so a clear's event, sent after // Sent while this holds the writer, before the commit, so a clear's event, sent after
@@ -777,18 +1302,26 @@ define_rpc! {
commands { commands {
execute_action(ActionInvocation) -> bool, execute_action(ActionInvocation) -> bool,
format_body(FormatBodyRequest) -> String, format_body(FormatBodyRequest) -> String,
get_proxy_state(GetProxyStateRequest) -> GetProxyStateResponse,
list_actions(ListActionsRequest) -> ListActionsResponse, list_actions(ListActionsRequest) -> ListActionsResponse,
list_flows(flows::FlowFilter) -> ListFlowsResponse, list_flows(flows::FlowFilter) -> ListFlowsResponse,
set_recording(SetRecordingRequest) -> bool,
set_system_proxy_at_launch(SetSystemProxyAtLaunchRequest) -> bool,
} }
async_commands { async_commands {
app_icon(AppIconRequest) -> Option<String>,
ca_status(CaStatusRequest) -> CaStatus, ca_status(CaStatusRequest) -> CaStatus,
ca_trust(CaTrustRequest) -> CaStatus, ca_trust(CaTrustRequest) -> CaStatus,
clear_flows(ClearFlowsRequest) -> usize, clear_flows(ClearFlowsRequest) -> usize,
export_har(ExportHarRequest) -> usize,
filter_body(FilterBodyRequest) -> FilterBodyResponse, filter_body(FilterBodyRequest) -> FilterBodyResponse,
get_flow_body(GetFlowBodyRequest) -> GetFlowBodyResponse, 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_set(SystemProxySetRequest) -> SystemProxyStatus,
system_proxy_status(SystemProxyStatusRequest) -> SystemProxyStatus, system_proxy_status(SystemProxyStatusRequest) -> SystemProxyStatus,
take_over(TakeOverRequest) -> bool,
} }
events { events {
flows_cleared(FlowsClearedPayload), flows_cleared(FlowsClearedPayload),
@@ -801,6 +1334,24 @@ define_rpc! {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn a_request_is_recorded_by_the_state_when_it_started() {
let mut log = RecordingLog::default();
let before = SystemTime::now();
std::thread::sleep(Duration::from_millis(5));
assert!(log.set(false));
assert!(!log.set(false), "no change");
std::thread::sleep(Duration::from_millis(5));
let while_paused = SystemTime::now();
std::thread::sleep(Duration::from_millis(5));
assert!(log.set(true));
// Read after recording resumed, each is judged by when it started.
assert!(log.is_on());
assert!(log.was_on_at(before));
assert!(!log.was_on_at(while_paused));
assert!(log.was_on_at(SystemTime::now()));
}
#[tokio::test] #[tokio::test]
async fn the_app_still_hears_why_a_body_failed_to_decode() { async fn the_app_still_hears_why_a_body_failed_to_decode() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+29 -5
View File
@@ -55,6 +55,14 @@ pub struct Flow {
pub error: Option<String>, pub error: Option<String>,
/// The id of the Yaak request this flow was last saved as. /// The id of the Yaak request this flow was last saved as.
pub saved_request_id: Option<String>, pub saved_request_id: Option<String>,
/// The process that opened the client connection, where the platform can tell (macOS).
pub client_pid: Option<i32>,
/// Its app's name, or its executable's outside an app: "Google Chrome", "curl".
pub client_name: Option<String>,
/// Its app's bundle id.
pub client_bundle_id: Option<String>,
/// Its app bundle's path, or its executable's outside an app.
pub client_path: Option<String>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
@@ -148,6 +156,10 @@ impl UpsertModelInfo for Flow {
(ElapsedMs, self.elapsed_ms.into()), (ElapsedMs, self.elapsed_ms.into()),
(Error, self.error.into()), (Error, self.error.into()),
(SavedRequestId, self.saved_request_id.into()), (SavedRequestId, self.saved_request_id.into()),
(ClientPid, self.client_pid.into()),
(ClientName, self.client_name.into()),
(ClientBundleId, self.client_bundle_id.into()),
(ClientPath, self.client_path.into()),
]) ])
} }
@@ -172,6 +184,10 @@ impl UpsertModelInfo for Flow {
FlowIden::ElapsedMs, FlowIden::ElapsedMs,
FlowIden::Error, FlowIden::Error,
FlowIden::SavedRequestId, FlowIden::SavedRequestId,
FlowIden::ClientPid,
FlowIden::ClientName,
FlowIden::ClientBundleId,
FlowIden::ClientPath,
] ]
} }
@@ -202,11 +218,19 @@ impl UpsertModelInfo for Flow {
elapsed_headers_ms: r.get("elapsed_headers_ms")?, elapsed_headers_ms: r.get("elapsed_headers_ms")?,
elapsed_ms: r.get("elapsed_ms")?, elapsed_ms: r.get("elapsed_ms")?,
error: r.get("error")?, error: r.get("error")?,
// Absent from a database read before its migrations have run. saved_request_id: added_column(r, "saved_request_id")?,
saved_request_id: match r.get("saved_request_id") { client_pid: added_column(r, "client_pid")?,
Err(rusqlite::Error::InvalidColumnName(_)) => None, client_name: added_column(r, "client_name")?,
v => v?, client_bundle_id: added_column(r, "client_bundle_id")?,
}, client_path: added_column(r, "client_path")?,
}) })
} }
} }
/// A column added by a later migration, absent from a database read before it has run.
fn added_column<T: rusqlite::types::FromSql>(r: &Row, name: &str) -> rusqlite::Result<Option<T>> {
match r.get(name) {
Err(rusqlite::Error::InvalidColumnName(_)) => Ok(None),
v => v,
}
}
+12 -4
View File
@@ -7,6 +7,7 @@ use crate::convert::{self, DroppedHeader};
use crate::models::Flow; use crate::models::Flow;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use ts_rs::TS;
use yaak_models::models::HttpRequest; use yaak_models::models::HttpRequest;
use yaak_models::query_manager::QueryManager; use yaak_models::query_manager::QueryManager;
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
@@ -17,16 +18,18 @@ pub fn client_data_dir() -> PathBuf {
yaak_common::data_dir::resolve_data_dir(app_id) yaak_common::data_dir::resolve_data_dir(app_id)
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct WorkspaceSummary { pub struct WorkspaceSummary {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub folders: Vec<FolderSummary>, pub folders: Vec<FolderSummary>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct FolderSummary { pub struct FolderSummary {
pub id: String, pub id: String,
pub name: String, pub name: String,
@@ -39,7 +42,7 @@ pub struct FolderSummary {
pub struct SaveFlowRequest { pub struct SaveFlowRequest {
pub workspace_id: String, pub workspace_id: String,
pub folder_id: Option<String>, pub folder_id: Option<String>,
/// Defaults to the URL's path. /// None or blank leaves the request unnamed, and Yaak shows its URL.
pub name: Option<String>, pub name: Option<String>,
} }
@@ -56,11 +59,16 @@ pub struct ClientDb {
} }
impl ClientDb { impl ClientDb {
/// Yaak has run on this machine, so there is a database to save into.
pub fn exists(data_dir: &Path) -> bool {
data_dir.join("db.sqlite").exists()
}
/// Opens `db.sqlite` in Yaak's data directory `data_dir`. Fails when there is none, rather /// Opens `db.sqlite` in Yaak's data directory `data_dir`. Fails when there is none, rather
/// than creating a database for a Yaak that has never run. /// than creating a database for a Yaak that has never run.
pub fn open(data_dir: &Path) -> Result<Self, String> { pub fn open(data_dir: &Path) -> Result<Self, String> {
let db_path = data_dir.join("db.sqlite"); let db_path = data_dir.join("db.sqlite");
if !db_path.exists() { if !Self::exists(data_dir) {
return Err(format!( return Err(format!(
"Yaak's database was not found at {}. Install Yaak and open it once.", "Yaak's database was not found at {}. Install Yaak and open it once.",
db_path.display() db_path.display()
+32 -6
View File
@@ -7,15 +7,22 @@ use yaak_proxy::DEFAULT_CAPTURE_BODY_LIMIT;
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
pub struct ProxySettings { pub struct ProxySettings {
pub port: u16, pub port: u16,
/// Whether the system proxy points at the engine while it runs. /// Point this Mac's web traffic at the engine as it starts. It is turned off again as the
pub system_proxy: bool, /// engine stops, and can be turned on and off any time between. Read from the earlier
/// `systemProxy` too, so a saved choice to keep it off carries over.
#[serde(alias = "systemProxy")]
pub system_proxy_at_launch: bool,
/// Bytes of each request and response body stored per flow. /// Bytes of each request and response body stored per flow.
pub capture_body_limit: usize, pub capture_body_limit: usize,
} }
impl Default for ProxySettings { impl Default for ProxySettings {
fn default() -> Self { fn default() -> Self {
Self { port: 9090, system_proxy: false, capture_body_limit: DEFAULT_CAPTURE_BODY_LIMIT } Self {
port: 9090,
system_proxy_at_launch: true,
capture_body_limit: DEFAULT_CAPTURE_BODY_LIMIT,
}
} }
} }
@@ -46,7 +53,7 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let settings = ProxySettings::load(&dir.path().join("settings.json")).unwrap(); let settings = ProxySettings::load(&dir.path().join("settings.json")).unwrap();
assert_eq!(settings.port, 9090); assert_eq!(settings.port, 9090);
assert!(!settings.system_proxy); assert!(settings.system_proxy_at_launch);
assert_eq!(settings.capture_body_limit, 10 * 1024 * 1024); assert_eq!(settings.capture_body_limit, 10 * 1024 * 1024);
} }
@@ -63,7 +70,8 @@ mod tests {
fn round_trip() { fn round_trip() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json"); let path = dir.path().join("settings.json");
let settings = ProxySettings { port: 8080, system_proxy: true, capture_body_limit: 1024 }; let settings =
ProxySettings { port: 8080, system_proxy_at_launch: false, capture_body_limit: 1024 };
settings.save(&path).unwrap(); settings.save(&path).unwrap();
assert_eq!(ProxySettings::load(&path).unwrap(), settings); assert_eq!(ProxySettings::load(&path).unwrap(), settings);
@@ -71,10 +79,28 @@ mod tests {
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!( assert_eq!(
json, json,
serde_json::json!({"port": 8080, "systemProxy": true, "captureBodyLimit": 1024}) serde_json::json!({"port": 8080, "systemProxyAtLaunch": false, "captureBodyLimit": 1024})
); );
} }
#[test]
fn the_earlier_system_proxy_key_carries_over() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
std::fs::write(&path, r#"{"port": 9090, "systemProxy": false}"#).unwrap();
assert!(!ProxySettings::load(&path).unwrap().system_proxy_at_launch);
std::fs::write(&path, r#"{"systemProxy": true}"#).unwrap();
assert!(ProxySettings::load(&path).unwrap().system_proxy_at_launch);
// Saved again under the new key only.
let settings = ProxySettings { system_proxy_at_launch: false, ..Default::default() };
settings.save(&path).unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(json["systemProxyAtLaunch"], false);
assert!(json.get("systemProxy").is_none());
}
#[test] #[test]
fn invalid_json_is_an_error() { fn invalid_json_is_an_error() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+338 -1
View File
@@ -10,6 +10,7 @@ use hyper::server::conn::http1;
use hyper::service::service_fn; use hyper::service::service_fn;
use hyper::{Request, Response}; use hyper::{Request, Response};
use hyper_util::rt::TokioIo; use hyper_util::rt::TokioIo;
use serde_json::{Value, json};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use yaak_models::models::{Folder, Workspace}; use yaak_models::models::{Folder, Workspace};
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
@@ -45,7 +46,7 @@ async fn spawn_upstream() -> SocketAddr {
/// A context over `dir` whose engine picks a free port, saving into Yaak's data in `client`. /// A context over `dir` whose engine picks a free port, saving into Yaak's data in `client`.
fn ctx(dir: &Path, client: &Path) -> ProxyCtx { fn ctx(dir: &Path, client: &Path) -> ProxyCtx {
let settings = ProxySettings { port: 0, ..Default::default() }; let settings = ProxySettings { port: 0, system_proxy_at_launch: false, ..Default::default() };
settings.save(&dir.join("settings.json")).unwrap(); settings.save(&dir.join("settings.json")).unwrap();
let (events, _rx) = RpcEventEmitter::new(); let (events, _rx) = RpcEventEmitter::new();
ProxyCtx::new(dir, events).unwrap().with_client_data_dir(client) ProxyCtx::new(dir, events).unwrap().with_client_data_dir(client)
@@ -138,6 +139,10 @@ async fn round_trip() {
assert!(client.flows(&following).await.unwrap().is_empty()); assert!(client.flows(&following).await.unwrap().is_empty());
let filter = FlowFilter { status: Some("4xx".into()), ..Default::default() }; let filter = FlowFilter { status: Some("4xx".into()), ..Default::default() };
assert!(client.flows(&filter).await.unwrap().is_empty()); assert!(client.flows(&filter).await.unwrap().is_empty());
let search = FlowFilter { q: Some("CHARGES post 2xx".into()), ..Default::default() };
assert_eq!(client.flows(&search).await.unwrap().len(), 1);
let search = FlowFilter { q: Some("charges get".into()), ..Default::default() };
assert!(client.flows(&search).await.unwrap().is_empty());
let detail = client.flow(&flow.id, 8).await.unwrap(); let detail = client.flow(&flow.id, 8).await.unwrap();
assert_eq!(detail.flow.method, "POST"); assert_eq!(detail.flow.method, "POST");
@@ -363,6 +368,15 @@ async fn save_writes_the_request_into_yaak() {
let flow = yaak_proxy_lib::flows::get(&ctx.db, &flow.id).unwrap(); let flow = yaak_proxy_lib::flows::get(&ctx.db, &flow.id).unwrap();
assert_eq!(flow.saved_request_id, Some(saved.request.id)); assert_eq!(flow.saved_request_id, Some(saved.request.id));
// Without a name, through the control API as the CLI saves, it stays unnamed, as Yaak
// leaves a new request, so Yaak shows its URL.
for name in [None, Some(" ".to_string())] {
let target = SaveFlowRequest { workspace_id: workspace.id.clone(), folder_id: None, name };
let saved = client.save(&flow.id, &target).await.unwrap();
let request = query_manager.connect().get_http_request(&saved.request.id).unwrap();
assert_eq!(request.name, "");
}
let wrong = SaveFlowRequest { workspace_id: "wk_missing".into(), ..Default::default() }; let wrong = SaveFlowRequest { workspace_id: "wk_missing".into(), ..Default::default() };
assert!(client.save(&flow.id, &wrong).await.unwrap_err().contains("No workspace")); assert!(client.save(&flow.id, &wrong).await.unwrap_err().contains("No workspace"));
} }
@@ -424,3 +438,326 @@ async fn start_is_refused_while_another_process_owns_the_engine() {
daemon.kill().unwrap(); daemon.kill().unwrap();
daemon.wait().unwrap(); daemon.wait().unwrap();
} }
async fn rpc(ctx: &ProxyCtx, cmd: &str, payload: Value) -> Result<Value, String> {
yaak_proxy_lib::build_router().dispatch(cmd, payload, ctx).await.map_err(|e| e.message)
}
#[tokio::test(flavor = "multi_thread")]
async fn the_app_takes_over_from_the_cli() {
let dir = tempfile::tempdir().unwrap();
let client_dir = tempfile::tempdir().unwrap();
let cli = ctx(dir.path(), client_dir.path()).with_owner(Owner::Cli);
let mut server = ControlServer::start(cli.clone()).unwrap();
let stop_rx = server.take_stop_rx().unwrap();
// The daemon exits as soon as it hears the stop.
let daemon = std::thread::spawn(move || {
stop_rx.recv().unwrap();
drop(server);
});
cli.start().unwrap();
let cli_port = cli.port().unwrap();
// The app opens onto the CLI's engine and cannot start its own.
let app = ctx(dir.path(), client_dir.path());
assert!(app.serve_control_api().unwrap_err().contains("already running in the yaak CLI"));
let state = rpc(&app, "get_proxy_state", json!({})).await.unwrap();
assert_eq!(state["state"], "stopped");
assert_eq!(state["otherOwner"]["owner"], "cli");
assert_eq!(state["otherOwner"]["proxyPort"], cli_port);
assert_eq!(state["otherOwner"]["pid"], std::process::id());
assert_eq!(rpc(&app, "take_over", json!({})).await.unwrap(), json!(true));
daemon.join().unwrap();
assert_eq!(cli.port(), None);
assert!(app.port().is_some());
let owner = control::read(dir.path()).unwrap();
assert_eq!((owner.owner, owner.proxy_port), (Owner::App, app.port()));
let state = rpc(&app, "get_proxy_state", json!({})).await.unwrap();
assert_eq!((state["state"].as_str(), &state["otherOwner"]), (Some("running"), &Value::Null));
// Taking over with no one else holding the engine only starts it.
assert_eq!(rpc(&app, "take_over", json!({})).await.unwrap(), json!(true));
app.stop().unwrap();
app.stop_control_api();
assert!(control::live_owner(dir.path()).is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn the_save_dialog_reads_workspaces_previews_and_saves() {
let dir = tempfile::tempdir().unwrap();
let client_dir = tempfile::tempdir().unwrap();
let ctx = ctx(dir.path(), client_dir.path());
ctx.start().unwrap();
let flow = capture_flow(&ctx).await;
// Before Yaak has ever run there is nothing to save into, and the dialog says so.
let listed = rpc(&ctx, "list_workspaces", json!({})).await.unwrap();
assert_eq!(listed, json!({ "yaakFound": false, "workspaces": [] }));
let (query_manager, _blobs, _rx) = yaak_models::init_standalone(
client_dir.path().join("db.sqlite"),
client_dir.path().join("blobs.sqlite"),
)
.unwrap();
let (workspace, folder) = query_manager
.with_tx(|tx| {
let workspace = tx.upsert_workspace(
&Workspace { name: "Payments".into(), ..Default::default() },
&UpdateSource::Background,
)?;
let folder = tx.upsert_folder(
&Folder {
name: "Stripe".into(),
workspace_id: workspace.id.clone(),
..Default::default()
},
&UpdateSource::Background,
)?;
Ok::<_, yaak_models::error::Error>((workspace, folder))
})
.unwrap();
let listed = rpc(&ctx, "list_workspaces", json!({})).await.unwrap();
assert_eq!(listed["yaakFound"], true);
assert_eq!(listed["workspaces"][0]["name"], "Payments");
assert_eq!(listed["workspaces"][0]["folders"][0]["id"], folder.id.as_str());
let preview = rpc(&ctx, "preview_save", json!({ "flowId": flow.id })).await.unwrap();
assert_eq!(preview["method"], "POST");
assert!(preview["requestUrl"].as_str().unwrap().ends_with("/v1/charges"), "{preview}");
assert_eq!(preview["bodyType"], "application/json");
assert_eq!(preview["body"], "JSON");
assert_eq!(preview["authorization"], true);
let dropped: Vec<&str> = preview["droppedHeaders"]
.as_array()
.unwrap()
.iter()
.map(|h| h["name"].as_str().unwrap())
.collect();
assert!(dropped.iter().any(|h| h.eq_ignore_ascii_case("host")), "{dropped:?}");
assert!(dropped.iter().any(|h| h.eq_ignore_ascii_case("content-length")), "{dropped:?}");
assert!(!dropped.iter().any(|h| h.eq_ignore_ascii_case("authorization")), "{dropped:?}");
assert_eq!(
preview["keptHeaders"].as_u64().unwrap() as usize + dropped.len(),
flow.request_headers.len()
);
assert!(preview["warnings"][0].as_str().unwrap().contains("Authorization"));
let saved = rpc(
&ctx,
"save_flow",
json!({
"flowId": flow.id,
"workspaceId": workspace.id,
"folderId": folder.id,
"name": "Create charge",
}),
)
.await
.unwrap();
assert_eq!(saved["name"], "Create charge");
let request_id = saved["requestId"].as_str().unwrap();
let request = query_manager.connect().get_http_request(request_id).unwrap();
assert_eq!(
(request.workspace_id.as_str(), request.folder_id.as_deref()),
(workspace.id.as_str(), Some(folder.id.as_str()))
);
let stored = ctx
.db
.with_conn(|db| db.find_one::<Flow>(yaak_proxy_lib::models::FlowIden::Id, &flow.id))
.unwrap();
assert_eq!(stored.saved_request_id.as_deref(), Some(request_id));
// A workspace that is gone is an error the dialog can show.
let err = rpc(&ctx, "save_flow", json!({ "flowId": flow.id, "workspaceId": "wk_gone" }))
.await
.unwrap_err();
assert!(err.contains("No workspace wk_gone"), "{err}");
}
#[tokio::test(flavor = "multi_thread")]
async fn export_har_writes_the_flows_asked_for() {
let dir = tempfile::tempdir().unwrap();
let client_dir = tempfile::tempdir().unwrap();
let ctx = ctx(dir.path(), client_dir.path());
ctx.start().unwrap();
let first = capture_flow(&ctx).await;
let second = capture_flow(&ctx).await;
let read =
|path: &Path| -> Value { serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap() };
let selected = dir.path().join("selected.har");
let written =
rpc(&ctx, "export_har", json!({ "path": selected, "ids": [first.id] })).await.unwrap();
assert_eq!(written, 1);
assert_eq!(read(&selected)["log"]["entries"].as_array().unwrap().len(), 1);
let searched = dir.path().join("searched.har");
let filter = json!({ "q": "charges", "limit": 1 });
let written =
rpc(&ctx, "export_har", json!({ "path": searched, "filter": filter })).await.unwrap();
// The whole search, not the page the list had loaded.
assert_eq!(written, 2);
let entries = read(&searched)["log"]["entries"].as_array().unwrap().len();
assert_eq!(entries, 2);
assert_ne!(first.id, second.id);
let none = dir.path().join("none.har");
let written =
rpc(&ctx, "export_har", json!({ "path": none, "filter": { "q": "nothing-matches" } }))
.await
.unwrap();
assert_eq!(written, 0);
}
/// curl, through the engine, shows as the app behind its flow.
#[cfg(target_os = "macos")]
#[tokio::test(flavor = "multi_thread")]
async fn flows_name_the_app_that_sent_them() {
let dir = tempfile::tempdir().unwrap();
let client_dir = tempfile::tempdir().unwrap();
let ctx = ctx(dir.path(), client_dir.path());
ctx.start().unwrap();
let upstream = spawn_upstream().await;
let url = format!("http://{upstream}/attributed");
let mut curl = std::process::Command::new("/usr/bin/curl")
.args(["-s", "-o", "/dev/null", "--max-time", "10", "--noproxy", ""])
.args([
"-x",
&format!("http://127.0.0.1:{}", ctx.port().unwrap()),
&url,
])
.spawn()
.unwrap();
let curl_pid = curl.id() as i32;
assert!(curl.wait().unwrap().success());
let deadline = Instant::now() + Duration::from_secs(5);
let flow = loop {
let flows = ctx.db.with_conn(|db| db.find_all::<Flow>().unwrap());
if let Some(flow) = flows.into_iter().find(|f| f.url == url) {
break flow;
}
assert!(Instant::now() < deadline, "no flow for {url}");
tokio::time::sleep(Duration::from_millis(20)).await;
};
assert_eq!(flow.client_name.as_deref(), Some("curl"));
assert_eq!(flow.client_pid, Some(curl_pid));
assert_eq!(flow.client_bundle_id, None);
assert_eq!(flow.client_path.as_deref(), Some("/usr/bin/curl"));
// The search and the sidebar's Apps group find it by its path.
let filter = FlowFilter { app: Some(vec!["/usr/bin/curl".into()]), ..Default::default() };
assert_eq!(yaak_proxy_lib::flows::list(&ctx.db, &filter).unwrap().len(), 1);
}
/// Sends one GET through the engine, without waiting for a flow.
async fn send_through(ctx: &ProxyCtx, path: &str) -> String {
let upstream = spawn_upstream().await;
let url = format!("http://{upstream}{path}");
let client = reqwest::Client::builder()
.proxy(reqwest::Proxy::http(format!("http://127.0.0.1:{}", ctx.port().unwrap())).unwrap())
.build()
.unwrap();
let resp = client.get(&url).send().await.unwrap();
assert_eq!(resp.status(), 200, "traffic passes through either way");
url
}
#[tokio::test(flavor = "multi_thread")]
async fn pausing_stores_no_flows_and_resuming_stores_them_again() {
let dir = tempfile::tempdir().unwrap();
let client_dir = tempfile::tempdir().unwrap();
let ctx = ctx(dir.path(), client_dir.path());
let _server = ControlServer::start(ctx.clone()).unwrap();
ctx.start().unwrap();
let client = ControlClient::new(control::read(dir.path()).unwrap());
assert!(client.status().await.unwrap().recording);
// Paused from the app.
assert_eq!(rpc(&ctx, "set_recording", json!({ "enabled": false })).await.unwrap(), false);
let state = rpc(&ctx, "get_proxy_state", json!({})).await.unwrap();
assert_eq!((state["state"].as_str(), &state["recording"]), (Some("running"), &json!(false)));
let paused = send_through(&ctx, "/while-paused").await;
tokio::time::sleep(Duration::from_millis(300)).await;
let flows = ctx.db.with_conn(|db| db.find_all::<Flow>().unwrap());
assert!(flows.iter().all(|f| f.url != paused), "a flow was stored while paused");
assert!(ctx.blobs.connect().list_body_ids().unwrap().is_empty(), "a body was stored");
// Resumed through the control API, as the CLI would.
let status = client.set_recording(true).await.unwrap();
assert!(status.recording);
assert!(ctx.recording());
let flow = capture_flow(&ctx).await;
assert_eq!(flow.method, "POST");
// And the hotkey's toggle pauses again.
rpc(&ctx, "execute_action", json!({ "scope": "global", "action": "recording_toggle" }))
.await
.unwrap();
assert!(!client.status().await.unwrap().recording);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_start_is_kept_for_the_app_until_the_engine_starts() {
let dir = tempfile::tempdir().unwrap();
let client_dir = tempfile::tempdir().unwrap();
let ctx = ctx(dir.path(), client_dir.path());
// Something else has the port.
let taken = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = taken.local_addr().unwrap().port();
let settings = ProxySettings { port, system_proxy_at_launch: false, ..Default::default() };
settings.save(&dir.path().join("settings.json")).unwrap();
assert!(ctx.start().is_err());
let state = rpc(&ctx, "get_proxy_state", json!({})).await.unwrap();
assert_eq!(state["state"], "stopped");
assert!(state["startError"].as_str().unwrap().contains(&port.to_string()), "{state}");
drop(taken);
ctx.start().unwrap();
let state = rpc(&ctx, "get_proxy_state", json!({})).await.unwrap();
assert_eq!((state["state"].as_str(), &state["startError"]), (Some("running"), &Value::Null));
ctx.stop().unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn the_system_proxy_at_launch_setting_is_saved_and_routing_needs_an_engine() {
let dir = tempfile::tempdir().unwrap();
let client_dir = tempfile::tempdir().unwrap();
let ctx = ctx(dir.path(), client_dir.path());
// Defaults on, like Proxyman.
assert!(ProxySettings::default().system_proxy_at_launch);
for enabled in [true, false] {
rpc(&ctx, "set_system_proxy_at_launch", json!({ "enabled": enabled })).await.unwrap();
assert_eq!(ctx.settings().unwrap().system_proxy_at_launch, enabled);
let state = rpc(&ctx, "get_proxy_state", json!({})).await.unwrap();
assert_eq!(state["systemProxyAtLaunch"], enabled);
}
// With no engine there is nothing to point the Mac at.
let err = rpc(&ctx, "system_proxy_set", json!({ "enabled": true })).await.unwrap_err();
assert!(err.contains("not running"), "{err}");
}
#[tokio::test(flavor = "multi_thread")]
async fn another_app_owning_the_engine_is_not_taken_over() {
let dir = tempfile::tempdir().unwrap();
let client_dir = tempfile::tempdir().unwrap();
// Another app instance, whose engine a stop would halt without releasing its claim.
let other = ctx(dir.path(), client_dir.path());
let _server = ControlServer::start(other.clone()).unwrap();
other.start().unwrap();
let app = ctx(dir.path(), client_dir.path());
let state = rpc(&app, "get_proxy_state", json!({})).await.unwrap();
assert_eq!(state["otherOwner"]["owner"], "app");
let err = rpc(&app, "take_over", json!({})).await.unwrap_err();
assert_eq!(err, yaak_proxy_lib::ANOTHER_APP_OWNS);
// Refused before touching it: the other app keeps running.
assert!(other.port().is_some());
assert_eq!(app.port(), None);
other.stop().unwrap();
}
+15 -4
View File
@@ -107,7 +107,7 @@ async fn wait_for_flow(ctx: &ProxyCtx, url: &str) -> Flow {
async fn flow_is_stored_with_its_bodies() { async fn flow_is_stored_with_its_bodies() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let settings = let settings =
ProxySettings { port: 0, capture_body_limit: CAPTURE_LIMIT, ..Default::default() }; ProxySettings { port: 0, capture_body_limit: CAPTURE_LIMIT, system_proxy_at_launch: false };
settings.save(&dir.path().join("settings.json")).unwrap(); settings.save(&dir.path().join("settings.json")).unwrap();
let (events, _event_rx) = RpcEventEmitter::new(); let (events, _event_rx) = RpcEventEmitter::new();
@@ -175,7 +175,7 @@ async fn flow_is_stored_with_its_bodies() {
async fn early_response_keeps_request_totals() { async fn early_response_keeps_request_totals() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let settings = let settings =
ProxySettings { port: 0, capture_body_limit: CAPTURE_LIMIT, ..Default::default() }; ProxySettings { port: 0, capture_body_limit: CAPTURE_LIMIT, system_proxy_at_launch: false };
settings.save(&dir.path().join("settings.json")).unwrap(); settings.save(&dir.path().join("settings.json")).unwrap();
let (events, _event_rx) = RpcEventEmitter::new(); let (events, _event_rx) = RpcEventEmitter::new();
@@ -208,7 +208,7 @@ async fn rpc(ctx: &ProxyCtx, cmd: &str, payload: Value) -> Result<Value, String>
async fn body_rpcs() { async fn body_rpcs() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let settings = let settings =
ProxySettings { port: 0, capture_body_limit: CAPTURE_LIMIT, ..Default::default() }; ProxySettings { port: 0, capture_body_limit: CAPTURE_LIMIT, system_proxy_at_launch: false };
settings.save(&dir.path().join("settings.json")).unwrap(); settings.save(&dir.path().join("settings.json")).unwrap();
let (events, _event_rx) = RpcEventEmitter::new(); let (events, _event_rx) = RpcEventEmitter::new();
@@ -217,7 +217,18 @@ async fn body_rpcs() {
let port = ctx.port().unwrap(); let port = ctx.port().unwrap();
let state = rpc(&ctx, "get_proxy_state", json!({})).await.unwrap(); let state = rpc(&ctx, "get_proxy_state", json!({})).await.unwrap();
assert_eq!(state, json!({"state": "running", "port": port})); assert_eq!(
state,
json!({
"state": "running",
"port": port,
"configuredPort": 0,
"recording": true,
"systemProxyAtLaunch": false,
"startError": null,
"otherOwner": null,
})
);
let upstream = spawn_upstream().await; let upstream = spawn_upstream().await;
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
+3 -1
View File
@@ -14,12 +14,14 @@ tauri-build = { version = "2.6.1", features = [] }
[features] [features]
default = ["wry"] default = ["wry"]
wry = ["tauri/wry", "tauri/x11", "tauri/dbus"] wry = ["tauri/wry", "tauri/x11", "tauri/dbus", "tauri-plugin-dialog/gtk3"]
[dependencies] [dependencies]
log = { workspace = true } log = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
tauri = { workspace = true, features = ["devtools"] } tauri = { workspace = true, features = ["devtools"] }
tauri-plugin-dialog = { workspace = true }
tauri-plugin-opener = "2.5.4"
tauri-plugin-os = "2.3.2" tauri-plugin-os = "2.3.2"
yaak-mac-window = { workspace = true } yaak-mac-window = { workspace = true }
yaak-proxy-lib = { workspace = true } yaak-proxy-lib = { workspace = true }
@@ -4,6 +4,9 @@
"windows": ["*"], "windows": ["*"],
"permissions": [ "permissions": [
"core:default", "core:default",
"dialog:allow-save",
"opener:allow-default-urls",
"opener:allow-open-url",
"os:allow-os-type", "os:allow-os-type",
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-is-fullscreen", "core:window:allow-is-fullscreen",
+19 -41
View File
@@ -1,9 +1,7 @@
use log::{error, info, warn}; use log::{error, info, warn};
use std::sync::Mutex;
use tauri::Runtime; use tauri::Runtime;
use tauri::{Emitter, Manager, RunEvent, State, WebviewWindow}; use tauri::{Emitter, Manager, RunEvent, State, WebviewWindow};
use yaak_proxy_lib::ProxyCtx; use yaak_proxy_lib::ProxyCtx;
use yaak_proxy_lib::control::{self, ControlServer};
use yaak_rpc::{RpcEventEmitter, RpcRouter}; use yaak_rpc::{RpcEventEmitter, RpcRouter};
use yaak_window::window::CreateWindowConfig; use yaak_window::window::CreateWindowConfig;
@@ -50,40 +48,6 @@ fn setup_window_menu<R: Runtime>(win: &WebviewWindow<R>) {
}); });
} }
/// The control API, served while this app owns the engine.
struct Control(Mutex<Option<ControlServer>>);
/// Claims the engine and serves the control API, unless a live owner (the CLI daemon) has it.
fn host_control_api(ctx: &ProxyCtx) -> Option<ControlServer> {
// Read first: a crashed owner's file names the port its system proxy may still point at.
let previous = control::read(ctx.data_dir());
if let Some(owner) = control::live_owner(ctx.data_dir()) {
// Starting the engine here is refused too, until the owner stops.
warn!(
"The proxy engine is owned by {:?} (pid {}), so this app does not serve the control API",
owner.owner, owner.pid
);
return None;
}
let server = match ControlServer::start(ctx.clone()) {
Ok(server) => server,
Err(e) => {
error!("Failed to start the proxy control API: {e}");
return None;
}
};
let mut ports: Vec<u16> = previous.and_then(|p| p.proxy_port).into_iter().collect();
ports.extend(ctx.settings().ok().map(|s| s.port));
let ctx = ctx.clone();
std::thread::spawn(move || {
if let Err(e) = ctx.reset_stale_system_proxy(&ports) {
warn!("Failed to reset a stale system proxy: {e}");
}
});
Some(server)
}
#[tauri::command] #[tauri::command]
async fn rpc( async fn rpc(
router: State<'_, RpcRouter<ProxyCtx>>, router: State<'_, RpcRouter<ProxyCtx>>,
@@ -97,6 +61,8 @@ async fn rpc(
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(yaak_mac_window::init()) .plugin(yaak_mac_window::init())
.setup(|app| { .setup(|app| {
let data_dir = app.path().app_data_dir().expect("no app data dir"); let data_dir = app.path().app_data_dir().expect("no app data dir");
@@ -104,7 +70,20 @@ pub fn run() {
let (emitter, event_rx) = RpcEventEmitter::new(); let (emitter, event_rx) = RpcEventEmitter::new();
let ctx = ProxyCtx::new(&data_dir, emitter)?; let ctx = ProxyCtx::new(&data_dir, emitter)?;
app.manage(Control(Mutex::new(host_control_api(&ctx)))); // Not while the CLI daemon owns the engine: the window offers to take it over.
match ctx.serve_control_api() {
// The engine runs for as long as the app does. A failure, such as the port
// being taken, is kept for the window to show.
Ok(()) => {
let ctx = ctx.clone();
std::thread::spawn(move || {
if let Err(e) = ctx.start() {
warn!("Failed to start the proxy: {e}");
}
});
}
Err(e) => warn!("Not serving the proxy control API: {e}"),
}
app.manage(ctx); app.manage(ctx);
app.manage(yaak_proxy_lib::build_router()); app.manage(yaak_proxy_lib::build_router());
@@ -126,10 +105,9 @@ pub fn run() {
.run(|app_handle, event| { .run(|app_handle, event| {
if let RunEvent::Exit = event { if let RunEvent::Exit = event {
// Never leave the Mac pointed at a proxy that is gone. // Never leave the Mac pointed at a proxy that is gone.
app_handle.state::<ProxyCtx>().shutdown(); let ctx = app_handle.state::<ProxyCtx>();
if let Ok(mut control) = app_handle.state::<Control>().0.lock() { ctx.shutdown();
control.take(); ctx.stop_control_api();
}
return; return;
} }
if let RunEvent::Ready = event { if let RunEvent::Ready = event {
+3 -2
View File
@@ -5,8 +5,8 @@ use hyper::service::service_fn;
use hyper_util::rt::TokioIo; use hyper_util::rt::TokioIo;
use tokio::io::{AsyncRead, AsyncWrite}; use tokio::io::{AsyncRead, AsyncWrite};
use crate::Engine;
use crate::request::handle_request; use crate::request::handle_request;
use crate::{ClientConn, Engine};
pub(crate) async fn serve_http1<S, F, Fut>(io: S, handler: F) -> Result<(), hyper::Error> pub(crate) async fn serve_http1<S, F, Fut>(io: S, handler: F) -> Result<(), hyper::Error>
where where
@@ -26,7 +26,8 @@ where
pub(crate) async fn handle_connection( pub(crate) async fn handle_connection(
stream: tokio::net::TcpStream, stream: tokio::net::TcpStream,
client: ClientConn,
engine: Arc<Engine>, engine: Arc<Engine>,
) -> Result<(), hyper::Error> { ) -> Result<(), hyper::Error> {
serve_http1(stream, move |req| handle_request(req, engine.clone())).await serve_http1(stream, move |req| handle_request(req, client, engine.clone())).await
} }
+40 -2
View File
@@ -42,8 +42,15 @@ pub struct ProxyConfig {
pub capture_body_limit: usize, pub capture_body_limit: usize,
/// Roots trusted for upstream TLS in addition to the platform's. /// Roots trusted for upstream TLS in addition to the platform's.
pub upstream_root_certs: Vec<CertificateDer<'static>>, pub upstream_root_certs: Vec<CertificateDer<'static>>,
/// Runs on a blocking thread as each client connection is accepted, with the connection's
/// number and the client's address, before anything is read from it, so the client is
/// still there to be looked up. The connection waits for it.
pub on_accept: Option<AcceptHook>,
} }
/// See [`ProxyConfig::on_accept`].
pub type AcceptHook = Arc<dyn Fn(u64, SocketAddr) + Send + Sync>;
impl ProxyConfig { impl ProxyConfig {
pub fn new(port: u16, ca: Arc<CaStore>) -> Self { pub fn new(port: u16, ca: Arc<CaStore>) -> Self {
Self { Self {
@@ -51,6 +58,7 @@ impl ProxyConfig {
ca, ca,
capture_body_limit: DEFAULT_CAPTURE_BODY_LIMIT, capture_body_limit: DEFAULT_CAPTURE_BODY_LIMIT,
upstream_root_certs: Vec::new(), upstream_root_certs: Vec::new(),
on_accept: None,
} }
} }
} }
@@ -69,6 +77,10 @@ pub enum ProxyEvent {
url: String, url: String,
/// Version spoken by the client. /// Version spoken by the client.
http_version: String, http_version: String,
/// The client connection the request came on, numbered from 1 for each run.
connection: u64,
/// The client's end of that connection, to find the app that opened it.
client_addr: SocketAddr,
}, },
RequestHeader { RequestHeader {
id: u64, id: u64,
@@ -149,6 +161,13 @@ impl ProxyHandle {
self.event_rx.take() self.event_rx.take()
} }
/// Forgets the hosts passed through after clients rejected the CA, so they are intercepted
/// again, as once the CA is trusted.
pub fn forget_passthrough(&self) {
self.engine.passthrough.lock().unwrap().clear();
self.engine.handshake_failures.lock().unwrap().clear();
}
/// Handshake failures counted toward passing `addr` (`host:port`) through, and whether it /// Handshake failures counted toward passing `addr` (`host:port`) through, and whether it
/// already is. For tests that must wait for the engine to record a failure. /// already is. For tests that must wait for the engine to record a failure.
#[doc(hidden)] #[doc(hidden)]
@@ -169,11 +188,19 @@ impl Drop for ProxyHandle {
} }
} }
/// The client connection a request came on.
#[derive(Debug, Clone, Copy)]
pub(crate) struct ClientConn {
connection: u64,
addr: SocketAddr,
}
pub(crate) struct Engine { pub(crate) struct Engine {
ca: Arc<CaStore>, ca: Arc<CaStore>,
capture_body_limit: usize, capture_body_limit: usize,
events: std_mpsc::Sender<ProxyEvent>, events: std_mpsc::Sender<ProxyEvent>,
next_id: AtomicU64, next_id: AtomicU64,
next_connection: AtomicU64,
http_client: Client<HttpConnector, BoxBody>, http_client: Client<HttpConnector, BoxBody>,
upstream_tls: Arc<ClientConfig>, upstream_tls: Arc<ClientConfig>,
upstream_tls_h1: Arc<ClientConfig>, upstream_tls_h1: Arc<ClientConfig>,
@@ -251,6 +278,7 @@ pub fn start_proxy(config: ProxyConfig) -> Result<ProxyHandle, String> {
capture_body_limit: config.capture_body_limit, capture_body_limit: config.capture_body_limit,
events: event_tx, events: event_tx,
next_id: AtomicU64::new(1), next_id: AtomicU64::new(1),
next_connection: AtomicU64::new(1),
http_client: Client::builder(TokioExecutor::new()).build_http(), http_client: Client::builder(TokioExecutor::new()).build_http(),
upstream_tls: upstream_tls_config(&roots, &[b"h2", b"http/1.1"])?, upstream_tls: upstream_tls_config(&roots, &[b"h2", b"http/1.1"])?,
upstream_tls_h1: upstream_tls_config(&roots, &[b"http/1.1"])?, upstream_tls_h1: upstream_tls_config(&roots, &[b"http/1.1"])?,
@@ -260,6 +288,7 @@ pub fn start_proxy(config: ProxyConfig) -> Result<ProxyHandle, String> {
}); });
let port = config.port; let port = config.port;
let on_accept = config.on_accept;
let handle_engine = engine.clone(); let handle_engine = engine.clone();
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
let (ready_tx, ready_rx) = std_mpsc::channel(); let (ready_tx, ready_rx) = std_mpsc::channel();
@@ -289,10 +318,19 @@ pub fn start_proxy(config: ProxyConfig) -> Result<ProxyHandle, String> {
loop { loop {
tokio::select! { tokio::select! {
result = listener.accept() => match result { result = listener.accept() => match result {
Ok((stream, _)) => { Ok((stream, addr)) => {
let engine = engine.clone(); let engine = engine.clone();
let client = ClientConn {
connection: engine.next_connection.fetch_add(1, Ordering::Relaxed),
addr,
};
let on_accept = on_accept.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = handle_connection(stream, engine).await { if let Some(hook) = on_accept {
let (connection, addr) = (client.connection, client.addr);
let _ = tokio::task::spawn_blocking(move || hook(connection, addr)).await;
}
if let Err(e) = handle_connection(stream, client, engine).await {
log::debug!("Client connection error: {e}"); log::debug!("Client connection error: {e}");
} }
}); });
+27 -9
View File
@@ -22,7 +22,7 @@ use tokio_rustls::{LazyConfigAcceptor, TlsConnector};
use crate::body::{CaptureBody, Direction}; use crate::body::{CaptureBody, Direction};
use crate::connection::serve_http1; use crate::connection::serve_http1;
use crate::tunnel::{Rewind, splice, tunnel}; use crate::tunnel::{Rewind, splice, tunnel};
use crate::{Engine, ProxyEvent}; use crate::{ClientConn, Engine, ProxyEvent};
pub(crate) type BoxBody = http_body_util::combinators::BoxBody<Bytes, hyper::Error>; pub(crate) type BoxBody = http_body_util::combinators::BoxBody<Bytes, hyper::Error>;
type BoxError = Box<dyn Error + Send + Sync>; type BoxError = Box<dyn Error + Send + Sync>;
@@ -67,7 +67,12 @@ struct Flow {
} }
impl Flow { impl Flow {
fn begin(engine: Arc<Engine>, req: &Request<Incoming>, url: String) -> Self { fn begin(
engine: Arc<Engine>,
client: ClientConn,
req: &Request<Incoming>,
url: String,
) -> Self {
let flow = Self { id: engine.next_flow_id(), start: Instant::now(), engine }; let flow = Self { id: engine.next_flow_id(), start: Instant::now(), engine };
flow.engine.emit(ProxyEvent::RequestStart { flow.engine.emit(ProxyEvent::RequestStart {
id: flow.id, id: flow.id,
@@ -75,6 +80,8 @@ impl Flow {
method: req.method().to_string(), method: req.method().to_string(),
url, url,
http_version: version_str(req.version()), http_version: version_str(req.version()),
connection: client.connection,
client_addr: client.addr,
}); });
for (name, value) in req.headers() { for (name, value) in req.headers() {
flow.engine.emit(ProxyEvent::RequestHeader { flow.engine.emit(ProxyEvent::RequestHeader {
@@ -147,16 +154,17 @@ fn bad_gateway(body: String) -> Response<BoxBody> {
pub(crate) async fn handle_request( pub(crate) async fn handle_request(
mut req: Request<Incoming>, mut req: Request<Incoming>,
client: ClientConn,
engine: Arc<Engine>, engine: Arc<Engine>,
) -> Result<Response<BoxBody>, Infallible> { ) -> Result<Response<BoxBody>, Infallible> {
if req.method() == Method::CONNECT { if req.method() == Method::CONNECT {
return Ok(handle_connect(req, engine)); return Ok(handle_connect(req, client, engine));
} }
// Some clients send a bare path (`POST /api`) as they would to the server itself. // Some clients send a bare path (`POST /api`) as they would to the server itself.
let from_host = req.uri().authority().is_none(); let from_host = req.uri().authority().is_none();
let rejected = if from_host { url_from_host(&mut req, &engine).err() } else { None }; let rejected = if from_host { url_from_host(&mut req, &engine).err() } else { None };
let flow = Flow::begin(engine.clone(), &req, req.uri().to_string()); let flow = Flow::begin(engine.clone(), client, &req, req.uri().to_string());
if let Some((status, error)) = rejected { if let Some((status, error)) = rejected {
return Ok(flow.reject(status, error)); return Ok(flow.reject(status, error));
} }
@@ -220,7 +228,11 @@ struct Target {
addr: String, addr: String,
} }
fn handle_connect(req: Request<Incoming>, engine: Arc<Engine>) -> Response<BoxBody> { fn handle_connect(
req: Request<Incoming>,
client: ClientConn,
engine: Arc<Engine>,
) -> Response<BoxBody> {
let Some(authority) = req.uri().authority().cloned() else { let Some(authority) = req.uri().authority().cloned() else {
return Response::builder() return Response::builder()
.status(StatusCode::BAD_REQUEST) .status(StatusCode::BAD_REQUEST)
@@ -240,7 +252,7 @@ fn handle_connect(req: Request<Incoming>, engine: Arc<Engine>) -> Response<BoxBo
authority: if port == 443 { host.clone() } else { format!("{host}:{port}") }, authority: if port == 443 { host.clone() } else { format!("{host}:{port}") },
addr: format!("{host}:{port}"), addr: format!("{host}:{port}"),
}; };
if let Err(e) = intercept(upgraded, target, engine).await { if let Err(e) = intercept(upgraded, target, client, engine).await {
debug!("Tunnel to {host}:{port} ended: {}", error_chain(e.as_ref())); debug!("Tunnel to {host}:{port} ended: {}", error_chain(e.as_ref()));
} }
}); });
@@ -248,7 +260,12 @@ fn handle_connect(req: Request<Incoming>, engine: Arc<Engine>) -> Response<BoxBo
Response::new(text_body(Bytes::new())) Response::new(text_body(Bytes::new()))
} }
async fn intercept<S>(mut client: S, target: Target, engine: Arc<Engine>) -> Result<(), BoxError> async fn intercept<S>(
mut client: S,
target: Target,
conn: ClientConn,
engine: Arc<Engine>,
) -> Result<(), BoxError>
where where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static, S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{ {
@@ -308,7 +325,7 @@ where
let tls = Rewind::new(Bytes::from(buf), tls); let tls = Rewind::new(Bytes::from(buf), tls);
let target = Arc::new(Target { server_name, ..target }); let target = Arc::new(Target { server_name, ..target });
serve_http1(tls, move |req| forward_https(req, target.clone(), engine.clone())).await?; serve_http1(tls, move |req| forward_https(req, target.clone(), conn, engine.clone())).await?;
Ok(()) Ok(())
} }
@@ -395,11 +412,12 @@ async fn connect_upstream(
async fn forward_https( async fn forward_https(
mut req: Request<Incoming>, mut req: Request<Incoming>,
target: Arc<Target>, target: Arc<Target>,
client: ClientConn,
engine: Arc<Engine>, engine: Arc<Engine>,
) -> Result<Response<BoxBody>, Infallible> { ) -> Result<Response<BoxBody>, Infallible> {
let path = req.uri().path_and_query().map(|pq| pq.to_string()).unwrap_or_else(|| "/".into()); let path = req.uri().path_and_query().map(|pq| pq.to_string()).unwrap_or_else(|| "/".into());
let url = format!("https://{}{path}", target.authority); let url = format!("https://{}{path}", target.authority);
let flow = Flow::begin(engine.clone(), &req, url.clone()); let flow = Flow::begin(engine.clone(), client, &req, url.clone());
let client_upgrade: Option<OnUpgrade> = let client_upgrade: Option<OnUpgrade> =
req.headers().contains_key(UPGRADE).then(|| hyper::upgrade::on(&mut req)); req.headers().contains_key(UPGRADE).then(|| hyper::upgrade::on(&mut req));
+10 -3
View File
@@ -352,12 +352,15 @@ async fn https_get() {
assert_eq!(resp.text().await.unwrap(), "hello"); assert_eq!(resp.text().await.unwrap(), "hello");
let events = h.flow_events(); let events = h.flow_events();
let (method, req_url, req_version, started_at) = find!(events, let (method, req_url, req_version, started_at, connection, client_addr) = find!(events,
ProxyEvent::RequestStart { method, url, http_version, started_at, .. } => ProxyEvent::RequestStart { method, url, http_version, started_at, connection, client_addr, .. } =>
(method.clone(), url.clone(), http_version.clone(), *started_at)); (method.clone(), url.clone(), http_version.clone(), *started_at, *connection, *client_addr));
assert_eq!(method, "GET"); assert_eq!(method, "GET");
assert_eq!(req_url, url); assert_eq!(req_url, url);
assert_eq!(req_version, "HTTP/1.1"); assert_eq!(req_version, "HTTP/1.1");
// The request inside the tunnel keeps the connection the CONNECT came on.
assert_eq!(connection, 1);
assert!(client_addr.ip().is_loopback() && client_addr.port() != h.proxy.port, "{client_addr}");
assert!(SystemTime::now().duration_since(started_at).unwrap() < Duration::from_secs(30)); assert!(SystemTime::now().duration_since(started_at).unwrap() < Duration::from_secs(30));
let (status, res_version, remote_addr, headers_ms) = find!(events, let (status, res_version, remote_addr, headers_ms) = find!(events,
@@ -635,6 +638,10 @@ async fn hang_up_after_the_handshake_passes_through() {
.connect(name, h.connect_tunnel(port).await) .connect(name, h.connect_tunnel(port).await)
.await .await
.expect("two hang-ups after the handshake should pass the host through"); .expect("two hang-ups after the handshake should pass the host through");
// Once the CA is trusted the host is intercepted again.
h.proxy.forget_passthrough();
assert_eq!(h.proxy.handshake_state(&addr), (0, false));
} }
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
+2
View File
@@ -248,6 +248,8 @@
"@tanstack/react-query": "^5.104.2", "@tanstack/react-query": "^5.104.2",
"@tanstack/react-virtual": "^3.14.17", "@tanstack/react-virtual": "^3.14.17",
"@tauri-apps/api": "^2.12.3", "@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", "@tauri-apps/plugin-os": "^2.4.0",
"@yaakapp-internal/proxy-lib": "^1.0.0", "@yaakapp-internal/proxy-lib": "^1.0.0",
"@yaakapp-internal/theme": "^1.0.0", "@yaakapp-internal/theme": "^1.0.0",
+4 -1
View File
@@ -7,7 +7,10 @@
"types": "src/index.ts", "types": "src/index.ts",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./editor": "./src/editor.ts" "./editor": "./src/editor.ts",
"./resolvedModelName": "./src/lib/resolvedModelName.ts",
"./templateTags": "./src/lib/templateTags.ts",
"./toast": "./src/lib/toast.ts"
}, },
"sideEffects": [ "sideEffects": [
"*.css" "*.css"
+74
View File
@@ -0,0 +1,74 @@
import classNames from "classnames";
import type { ReactNode } from "react";
import { Icon } from "./Icon";
import { IconTooltip } from "./IconTooltip";
import { HStack } from "./Stacks";
export interface CheckboxProps {
checked: boolean | "indeterminate";
title: ReactNode;
onChange: (checked: boolean) => void;
className?: string;
disabled?: boolean;
inputWrapperClassName?: string;
hideLabel?: boolean;
fullWidth?: boolean;
help?: ReactNode;
size?: "sm" | "md";
}
export function Checkbox({
checked,
onChange,
className,
inputWrapperClassName,
disabled,
title,
hideLabel,
fullWidth,
help,
size = "sm",
}: CheckboxProps) {
return (
<HStack
as="label"
alignItems="center"
space={2}
className={classNames(className, "text-text mr-auto")}
>
<div className={classNames(inputWrapperClassName, "x-theme-input", "relative flex mr-0.5")}>
<input
aria-hidden
className={classNames(
"appearance-none shrink-0 border border-border",
size === "sm" && "w-4 h-4",
size === "md" && "w-5 h-5",
"rounded-sm outline-hidden ring-0",
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
disabled && "border-dotted",
)}
type="checkbox"
disabled={disabled}
onChange={() => {
onChange(checked === "indeterminate" ? true : !checked);
}}
/>
<div className="absolute inset-0 flex items-center justify-center">
<Icon
size={size}
className={classNames(disabled && "opacity-disabled")}
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
/>
</div>
</div>
{!hideLabel && (
<div
className={classNames("text-sm", fullWidth && "w-full", disabled && "opacity-disabled")}
>
{title}
</div>
)}
{help && <IconTooltip content={help} />}
</HStack>
);
}
+202
View File
@@ -0,0 +1,202 @@
import type { DialogSize } from "@yaakapp-internal/plugins";
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 { Heading } from "./Heading";
import { IconButton } from "./IconButton";
import { Overlay } from "./Overlay";
import { HStack } from "./Stacks";
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<HTMLDivElement | null>(null);
const descriptionId = useMemo(
() => (description ? Math.random().toString(36).slice(2) : undefined),
[description],
);
return (
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
<div
role="dialog"
className={classNames(
"py-4 x-theme-dialog absolute inset-0 pointer-events-none",
"h-full flex flex-col items-center justify-center",
vAlign === "top" && "justify-start",
vAlign === "center" && "justify-center",
)}
aria-labelledby={titleId}
aria-describedby={descriptionId}
tabIndex={-1}
onKeyDown={(e) => {
// 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();
}
}}
>
<m.div
initial={{ top: 5, scale: 0.97 }}
animate={{ top: 0, scale: 1 }}
className={classNames(
className,
"grid grid-rows-[auto_auto_minmax(0,1fr)_auto]",
"grid-cols-1", // must be here for inline code blocks to correctly break words
"relative bg-surface pointer-events-auto",
"rounded-lg",
"border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]",
"min-h-40",
"max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]",
size === "sm" && "w-120",
size === "md" && "w-200",
size === "lg" && "w-280",
size === "full" && "w-screen h-screen",
size === "dynamic" && "min-w-80 max-w-[100vw]",
)}
>
{title ? (
<Heading className="px-6 mt-4 mb-2" level={1} id={titleId}>
{title}
</Heading>
) : (
<span />
)}
{description ? (
<div className="min-h-0 px-6 text-text-subtle mb-3" id={descriptionId}>
{description}
</div>
) : (
<span />
)}
<div
className={classNames(
"h-full w-full grid grid-cols-[minmax(0,1fr)] grid-rows-1",
!noPadding && "px-6 py-2",
!noScroll && "overflow-y-auto overflow-x-hidden",
)}
>
<DialogFooterSlot.Provider value={footerEl}>{children}</DialogFooterSlot.Provider>
</div>
<div ref={setFooterEl} className="contents" />
{/*Put close at the end so that it's the last thing to be tabbed to*/}
{!disableClose && !hideX && (
<div className="ml-auto absolute right-1 top-1">
<IconButton
className="opacity-70 hover:opacity-100"
onClick={onClose}
title="Close dialog (Esc)"
aria-label="Close"
size="sm"
icon="x"
/>
</div>
)}
</m.div>
</div>
</Overlay>
);
}
const DialogFooterSlot = createContext<HTMLDivElement | null>(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 = (
<footer
className={classNames(
"px-6 flex items-center gap-3",
inline ? "pt-1 pb-4" : "py-3 border-t border-border-subtle",
)}
>
{leftSlot != null && <div className="mr-auto min-w-0">{leftSlot}</div>}
<HStack space={2} justifyContent="end" className="ml-auto shrink-0">
{actions.map((action, i) => (
<Button
// Labels change with state ("Cloning...", "Apply 3 Changes"), so they can't be keys
key={i}
type={action.form != null ? "submit" : "button"}
form={action.form}
color={action.color ?? "secondary"}
variant={action.variant ?? (action.color == null ? "border" : "solid")}
disabled={action.disabled}
isLoading={action.isLoading}
autoFocus={action.autoFocus}
leftSlot={action.leftSlot}
rightSlot={action.rightSlot}
onClick={action.onClick}
>
{action.label}
</Button>
))}
</HStack>
</footer>
);
return slot == null ? footer : createPortal(footer, slot);
}
+2
View File
@@ -20,6 +20,7 @@ import {
ArrowUpFromLineIcon, ArrowUpFromLineIcon,
ArrowUpIcon, ArrowUpIcon,
BadgeCheckIcon, BadgeCheckIcon,
BookmarkIcon,
BookOpenText, BookOpenText,
BoxIcon, BoxIcon,
CakeIcon, CakeIcon,
@@ -166,6 +167,7 @@ const icons = {
arrow_up_from_dot: ArrowUpFromDotIcon, arrow_up_from_dot: ArrowUpFromDotIcon,
arrow_up_from_line: ArrowUpFromLineIcon, arrow_up_from_line: ArrowUpFromLineIcon,
badge_check: BadgeCheckIcon, badge_check: BadgeCheckIcon,
bookmark: BookmarkIcon,
book_open_text: BookOpenText, book_open_text: BookOpenText,
box: BoxIcon, box: BoxIcon,
cake: CakeIcon, cake: CakeIcon,
+254
View File
@@ -0,0 +1,254 @@
import classNames from "classnames";
import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react";
import {
forwardRef,
useCallback,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
import { generateId } from "@yaakapp-internal/lib/generateId";
import { useRandomKey } from "../hooks/useRandomKey";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { IconButton } from "./IconButton";
import type { InputProps } from "./Input";
import { Label } from "./Label";
import { HStack } from "./Stacks";
export type PlainInputProps = Omit<
InputProps,
| "wrapLines"
| "onKeyDown"
| "type"
| "stateKey"
| "extensions"
| "renderEditor"
| "renderOptionsPicker"
| "language"
> &
Pick<InputHTMLAttributes<HTMLInputElement>, "inputMode" | "onKeyDownCapture" | "step"> & {
onFocusRaw?: InputHTMLAttributes<HTMLInputElement>["onFocus"];
type?: "text" | "password" | "number";
hideObscureToggle?: boolean;
labelRightSlot?: ReactNode;
/** Ignored, so a field's props can go to `Input` or `PlainInput` alike */
language?: string | null;
/** Ignored, as `language` is */
showOptionsPicker?: boolean;
};
declare module "react" {
interface HTMLAttributes<T> {
// Not in @types/react yet; opts editable elements out of macOS Writing Tools
writingsuggestions?: "true" | "false";
}
}
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<boolean>(false, [forceUpdateKey]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle<{ focus: () => void } | null, { focus: () => void } | null>(
ref,
() => inputRef.current,
);
const handleFocus = useCallback(
(e: FocusEvent<HTMLInputElement>) => {
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<HTMLDivElement>(null);
return (
<div
ref={wrapperRef}
className={classNames(
"w-full",
"pointer-events-auto", // Just in case we're placing in disabled parent
labelPosition === "left" && "flex items-center gap-2",
labelPosition === "top" && "flex-row gap-0.5",
)}
>
<Label
htmlFor={id.current}
className={labelClassName}
visuallyHidden={hideLabel}
required={required}
help={help}
rightSlot={labelRightSlot}
>
{label}
</Label>
<HStack
alignItems="stretch"
className={classNames(
containerClassName,
"x-theme-input",
"relative w-full rounded-md text",
"border",
"overflow-hidden",
focused && !disabled ? "border-border-focus" : "border-border-subtle",
disabled && "border-dotted",
hasChanged && "has-invalid:border-danger", // For built-in HTML validation
size === "md" && "min-h-md",
size === "sm" && "min-h-sm",
size === "xs" && "min-h-xs",
size === "2xs" && "min-h-2xs",
)}
>
{tint != null && (
<div
aria-hidden
className={classNames(
"absolute inset-0 opacity-5 pointer-events-none",
tint === "info" && "bg-info",
tint === "warning" && "bg-warning",
)}
/>
)}
{leftSlot}
<HStack
className={classNames(
"w-full min-w-0",
leftSlot ? "pl-0.5 -ml-2" : null,
rightSlot ? "pr-0.5 -mr-2" : null,
)}
>
<input
id={id.current}
ref={inputRef}
key={forceUpdateKey}
type={type === "password" && !obscured ? "text" : type}
name={name}
// oxlint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
defaultValue={defaultValue ?? undefined}
disabled={disabled}
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
writingsuggestions="false"
inputMode={inputMode}
onChange={(e) => handleChange(e.target.value)}
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
className={classNames(commonClassName, "h-full disabled:opacity-disabled")}
onFocus={handleFocus}
onBlur={handleBlur}
required={required}
step={step}
placeholder={placeholder}
onKeyDownCapture={onKeyDownCapture}
/>
</HStack>
{type === "password" && !hideObscureToggle && (
<IconButton
title={
obscured
? `Show ${typeof label === "string" ? label : "field"}`
: `Obscure ${typeof label === "string" ? label : "field"}`
}
size="xs"
className="mr-0.5 group/obscure h-auto! my-0.5"
iconClassName="group-hover/obscure:text"
iconSize="sm"
icon={obscured ? "eye" : "eye_closed"}
onClick={() => setObscured((o) => !o)}
/>
)}
{rightSlot}
</HStack>
</div>
);
});
function validateRequire(v: string) {
return v.length > 0;
}
+149
View File
@@ -0,0 +1,149 @@
import type { ShowToastRequest } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import * as m from "motion/react-m";
import type { ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useKey } from "react-use";
import { Icon, type IconProps } from "./Icon";
import { IconButton } from "./IconButton";
import { VStack } from "./Stacks";
export interface ToastProps {
children: ReactNode;
open: boolean;
onClose: () => void;
className?: string;
timeout: number | null;
action?: (args: { hide: () => void }) => ReactNode;
icon?: ShowToastRequest["icon"] | null;
color?: ShowToastRequest["color"];
// Grow with the content (up to the viewport) instead of scrolling internally
// past the default max height
dynamicHeight?: boolean;
// Hide the close button, for toasts that render their own dismiss action.
// Escape still closes the toast
hideDismiss?: boolean;
}
const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon"] | null> = {
custom: null,
danger: "alert_triangle",
info: "info",
notice: "alert_triangle",
primary: "info",
secondary: "info",
success: "check_circle",
warning: "alert_triangle",
};
export function Toast({
children,
open,
onClose,
timeout,
action,
icon,
color,
dynamicHeight,
hideDismiss,
}: ToastProps) {
const onCloseRef = useRef(onClose);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [autoHideCanceled, setAutoHideCanceled] = useState(false);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
const cancelAutoHide = useCallback(() => {
if (timeoutRef.current == null) return;
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
setAutoHideCanceled(true);
}, []);
useEffect(() => {
if (!open || timeout == null || autoHideCanceled) return;
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
onCloseRef.current();
}, timeout);
return () => {
if (timeoutRef.current == null) return;
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
};
}, [autoHideCanceled, open, timeout]);
useKey(
"Escape",
() => {
if (!open) return;
onClose();
},
{},
[open],
);
const toastIcon = icon === null ? null : (icon ?? (color && color in ICONS && ICONS[color]));
return (
<m.div
initial={{ opacity: 0, right: "-10%" }}
animate={{ opacity: 100, right: 0 }}
exit={{ opacity: 0, right: "-100%" }}
transition={{ duration: 0.2 }}
className={classNames("bg-surface m-2 rounded-lg")}
>
<div
className={classNames(
`x-theme-toast x-theme-toast--${color}`,
"pointer-events-auto overflow-hidden",
"relative pointer-events-auto bg-surface text-text rounded-lg",
"border border-border shadow-lg w-100",
)}
onFocusCapture={cancelAutoHide}
onKeyDownCapture={cancelAutoHide}
onPointerDownCapture={cancelAutoHide}
>
<div
className={classNames(
"pl-3 py-3 flex items-start gap-2 w-full overflow-auto",
hideDismiss ? "pr-3" : "pr-10",
dynamicHeight ? "max-h-[80vh]" : "max-h-44",
)}
>
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />}
<VStack space={2} className="w-full min-w-0">
<div className="select-auto">{children}</div>
{action?.({ hide: onClose })}
</VStack>
</div>
{!hideDismiss && (
<IconButton
color={color}
variant="border"
className="opacity-60 border-0 absolute! top-2 right-2"
title="Dismiss"
icon="x"
onClick={onClose}
/>
)}
{timeout != null && !autoHideCanceled && (
<div className="w-full absolute bottom-0 left-0 right-0">
<m.div
className="bg-surface-highlight h-[3px]"
initial={{ width: "100%" }}
animate={{ width: "0%", opacity: 0.2 }}
transition={{ duration: timeout / 1000, ease: "linear" }}
/>
</div>
)}
</div>
</m.div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { useAtomValue, useStore } from "jotai";
import { AnimatePresence } from "motion/react";
import { hideToast, type ToastInstance, toastsAtom } from "../lib/toast";
import { ErrorBoundary } from "./ErrorBoundary";
import { Portal } from "./Portal";
import { Toast } from "./Toast";
/** The toasts in the app's jotai store, bottom right */
export function Toasts({ onShowError }: { onShowError?: (error: unknown) => void }) {
const store = useStore();
const toasts = useAtomValue(toastsAtom);
return (
<Portal name="toasts">
<div className="absolute right-0 bottom-0 z-50">
<AnimatePresence>
{toasts.map((toast: ToastInstance) => {
const { message, uniqueKey, ...props } = toast;
return (
<ErrorBoundary key={uniqueKey} name={`Toast ${uniqueKey}`} onShowError={onShowError}>
<Toast
open
{...props}
// We call onClose inside actions.hide instead of passing to toast so that
// it gets called from external close calls as well
onClose={() => hideToast(store, toast)}
>
{message}
</Toast>
</ErrorBoundary>
);
})}
</AnimatePresence>
</div>
</Portal>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { useCallback, useState } from "react";
import { generateId } from "@yaakapp-internal/lib/generateId";
export function useRandomKey(initialValue?: string) {
const [value, setValue] = useState<string>(initialValue ?? generateId());
const regenerate = useCallback(() => setValue(generateId()), []);
return [value, regenerate] as const;
}
+12
View File
@@ -2,11 +2,15 @@ export type { BannerProps } from "./components/Banner";
export { Banner } from "./components/Banner"; export { Banner } from "./components/Banner";
export type { ButtonProps } from "./components/Button"; export type { ButtonProps } from "./components/Button";
export { Button } from "./components/Button"; export { Button } from "./components/Button";
export type { CheckboxProps } from "./components/Checkbox";
export { Checkbox } from "./components/Checkbox";
export type { CopyIconButtonProps } from "./components/CopyIconButton"; export type { CopyIconButtonProps } from "./components/CopyIconButton";
export { CopyIconButton } from "./components/CopyIconButton"; export { CopyIconButton } from "./components/CopyIconButton";
export { CountBadge } from "./components/CountBadge"; export { CountBadge } from "./components/CountBadge";
export type { DetailsBannerProps } from "./components/DetailsBanner"; export type { DetailsBannerProps } from "./components/DetailsBanner";
export { DetailsBanner } from "./components/DetailsBanner"; export { DetailsBanner } from "./components/DetailsBanner";
export type { DialogFooterAction, DialogProps } from "./components/Dialog";
export { Dialog, DialogFooter } from "./components/Dialog";
export { DropMarker } from "./components/DropMarker"; export { DropMarker } from "./components/DropMarker";
export type { export type {
ContextMenuProps, ContextMenuProps,
@@ -40,6 +44,8 @@ export { KeyValueRow, KeyValueRows } from "./components/KeyValueRow";
export { Label } from "./components/Label"; export { Label } from "./components/Label";
export { LoadingIcon } from "./components/LoadingIcon"; export { LoadingIcon } from "./components/LoadingIcon";
export { Overlay } from "./components/Overlay"; export { Overlay } from "./components/Overlay";
export type { PlainInputProps } from "./components/PlainInput";
export { PlainInput } from "./components/PlainInput";
export { Portal } from "./components/Portal"; export { Portal } from "./components/Portal";
export type { SeparatorAction } from "./components/Separator"; export type { SeparatorAction } from "./components/Separator";
export { Separator } from "./components/Separator"; export { Separator } from "./components/Separator";
@@ -68,6 +74,9 @@ export type {
TabsStorage, TabsStorage,
} from "./components/tabs/Tabs"; } from "./components/tabs/Tabs";
export { TabContent, Tabs } from "./components/tabs/Tabs"; export { TabContent, Tabs } from "./components/tabs/Tabs";
export type { ToastProps } from "./components/Toast";
export { Toast } from "./components/Toast";
export { Toasts } from "./components/Toasts";
export { HttpMethodTag, HttpMethodTagRaw } from "./components/tags/HttpMethodTag"; export { HttpMethodTag, HttpMethodTagRaw } from "./components/tags/HttpMethodTag";
export { formatMillis, HttpResponseDurationTag } from "./components/tags/HttpResponseDurationTag"; export { formatMillis, HttpResponseDurationTag } from "./components/tags/HttpResponseDurationTag";
export { HttpStatusTag, HttpStatusTagRaw } from "./components/tags/HttpStatusTag"; export { HttpStatusTag, HttpStatusTagRaw } from "./components/tags/HttpStatusTag";
@@ -104,6 +113,7 @@ export type {
} from "./hooks/useHotKey"; } from "./hooks/useHotKey";
export { formatHotkey, HotkeyProvider, useFormattedHotkey, useHotKey } from "./hooks/useHotKey"; export { formatHotkey, HotkeyProvider, useFormattedHotkey, useHotKey } from "./hooks/useHotKey";
export { useIsFullscreen } from "./hooks/useIsFullscreen"; export { useIsFullscreen } from "./hooks/useIsFullscreen";
export { useRandomKey } from "./hooks/useRandomKey";
export { usePortal } from "./hooks/usePortal"; export { usePortal } from "./hooks/usePortal";
export { useStateWithDeps } from "./hooks/useStateWithDeps"; export { useStateWithDeps } from "./hooks/useStateWithDeps";
export type { SetStoredValue, StoredValue, UseStoredValue } from "./hooks/useStoredValue"; export type { SetStoredValue, StoredValue, UseStoredValue } from "./hooks/useStoredValue";
@@ -118,3 +128,5 @@ export { computeSideForDragMove } from "./lib/dnd";
export { docFingerprint } from "./lib/docFingerprint"; export { docFingerprint } from "./lib/docFingerprint";
export { getNodeText } from "./lib/getNodeText"; export { getNodeText } from "./lib/getNodeText";
export { minPromiseMillis } from "./lib/minPromiseMillis"; export { minPromiseMillis } from "./lib/minPromiseMillis";
export type { ShowToastArgs, ToastInstance } from "./lib/toast";
export { hideToast, hideToastById, showToast, toastsAtom } from "./lib/toast";
@@ -0,0 +1,18 @@
import { describe, expect, test } from "vite-plus/test";
import { unnamedRequestName } from "./resolvedModelName";
describe("unnamedRequestName", () => {
test("is the URL without its protocol, as Yaak shows an unnamed request", () => {
const url = "http://ocsp2.apple.com/ocsp04-wwdrg312/ME4wTKADAgEAMEUwQzBBMAkGBSsOAwIaBQAEFA";
expect(unnamedRequestName({ model: "http_request", url })).toBe(
"ocsp2.apple.com/ocsp04-wwdrg312/ME4wTKADAgEAMEUwQzBBMAkGBSsOAwIaBQAEFA",
);
});
test("names the kind of request when there is no URL", () => {
expect(unnamedRequestName({ model: "http_request", url: "" })).toBe("HTTP Request");
expect(unnamedRequestName({ model: "http_request", url: " ", bodyType: "graphql" })).toBe(
"GraphQL Request",
);
});
});
+60
View File
@@ -0,0 +1,60 @@
import type { AnyModel } from "@yaakapp-internal/models";
import { replaceTemplateTags } from "./templateTags";
/**
* The name Yaak shows for a model: its own, or for a request without one its URL, with
* template tags reduced to their contents and the protocol left off.
*/
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;
}
return unnamedRequestName(r);
}
/** What a request, or its response or connection, needs for the name Yaak shows without one */
export interface UnnamedRequest {
/** `http_request`, `websocket_request`, and any other model reads as gRPC, as in Yaak */
model: string;
url: string;
bodyType?: string | null;
service?: string | null;
method?: string | null;
}
/**
* The name Yaak shows for a request without one: its URL, with template tags reduced to their
* contents and the protocol left off, or the kind of request when the URL is empty.
*/
export function unnamedRequestName(r: UnnamedRequest): string {
// 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;
}
+137
View File
@@ -0,0 +1,137 @@
/*
* 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
* `apps/yaak-client/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<number> | 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);
}
+58
View File
@@ -0,0 +1,58 @@
import { generateId } from "@yaakapp-internal/lib/generateId";
import { atom, type createStore } from "jotai";
import type { ReactNode } from "react";
import type { ToastProps } from "../components/Toast";
type Store = ReturnType<typeof createStore>;
export type ToastInstance = {
id: string;
uniqueKey: string;
message: ReactNode;
timeout: 3000 | 5000 | 8000 | (number & {}) | null;
onClose?: ToastProps["onClose"];
} & Omit<ToastProps, "onClose" | "open" | "children" | "timeout">;
export type ShowToastArgs = Omit<ToastInstance, "id" | "timeout" | "uniqueKey"> & {
id?: ToastInstance["id"];
timeout?: ToastInstance["timeout"];
};
/** The toasts on screen, which `Toasts` renders from the app's store */
export const toastsAtom = atom<ToastInstance[]>([]);
/** Shows a toast in `store`, replacing one with the same id. Returns its id. */
export function showToast(store: Store, { id, timeout = 5000, ...props }: ShowToastArgs) {
const toastId = id ?? generateId();
const uniqueKey = generateId();
const toasts = store.get(toastsAtom);
const toastWithSameId = toasts.find((t) => t.id === toastId);
let delay = 0;
if (toastWithSameId) {
hideToast(store, toastWithSameId);
// Allow enough time for old toast to animate out
delay = 200;
}
setTimeout(() => {
const newToast: ToastInstance = { id: toastId, uniqueKey, timeout, ...props };
store.set(toastsAtom, (prev) => [...prev, newToast]);
}, delay);
return toastId;
}
export function hideToastById(store: Store, id: string) {
const toast = store.get(toastsAtom).find((t) => t.id === id);
if (toast) hideToast(store, toast);
}
export function hideToast(store: Store, toHide: ToastInstance) {
store.set(toastsAtom, (all) => {
const t = all.find((t) => t.uniqueKey === toHide.uniqueKey);
t?.onClose?.();
return all.filter((t) => t.uniqueKey !== toHide.uniqueKey);
});
}