mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Proxy GUI pass (#793)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
96ab0a8a60
commit
e82ffc53ad
73 files changed
+6003
-1257
No files matched your search
Generated
+6
@@ -11375,6 +11375,8 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-os",
|
||||
"yaak-mac-window",
|
||||
"yaak-proxy-lib",
|
||||
@@ -11765,6 +11767,10 @@ dependencies = [
|
||||
"include_dir",
|
||||
"libc",
|
||||
"log 0.4.34",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation 0.3.2",
|
||||
"plist",
|
||||
"r2d2",
|
||||
"r2d2_sqlite",
|
||||
"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} /> });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,43 +1,6 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
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";
|
||||
import { Toasts as BaseToasts } from "@yaakapp-internal/ui";
|
||||
import { showErrorDialog } from "./ErrorBoundary";
|
||||
|
||||
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 { ToastInstance } from "@yaakapp-internal/ui";
|
||||
|
||||
export const Toasts = () => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
export const Toasts = () => <BaseToasts onShowError={showErrorDialog} />;
|
||||
@@ -1,72 +1,2 @@
|
||||
import { HStack, Icon, IconTooltip } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
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>
|
||||
);
|
||||
}
|
||||
export type { CheckboxProps } from "@yaakapp-internal/ui";
|
||||
export { Checkbox } from "@yaakapp-internal/ui";
|
||||
@@ -1,200 +1,2 @@
|
||||
import type { DialogSize } from "@yaakapp-internal/plugins";
|
||||
import { Heading, HStack, Overlay } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import * as m from "motion/react-m";
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, useContext, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button, type ButtonProps } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
export interface DialogProps {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
|
||||
disableClose?: boolean;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
size?: DialogSize;
|
||||
/** Hide the built-in close button without changing backdrop or Escape behavior. */
|
||||
hideX?: boolean;
|
||||
noPadding?: boolean;
|
||||
noScroll?: boolean;
|
||||
vAlign?: "top" | "center";
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
children,
|
||||
className,
|
||||
size = "full",
|
||||
open,
|
||||
onClose,
|
||||
disableClose,
|
||||
title,
|
||||
description,
|
||||
hideX,
|
||||
noPadding,
|
||||
noScroll,
|
||||
vAlign = "center",
|
||||
}: DialogProps) {
|
||||
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
|
||||
const [footerEl, setFooterEl] = useState<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);
|
||||
}
|
||||
export type { DialogFooterAction, DialogProps } from "@yaakapp-internal/ui";
|
||||
export { Dialog, DialogFooter } from "@yaakapp-internal/ui";
|
||||
@@ -1,242 +1,2 @@
|
||||
import { HStack, useStateWithDeps, Label } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useRandomKey } from "../../hooks/useRandomKey";
|
||||
import { IconButton } from "./IconButton";
|
||||
import type { InputProps } from "./Input";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
|
||||
export type PlainInputProps = Omit<
|
||||
InputProps,
|
||||
| "wrapLines"
|
||||
| "onKeyDown"
|
||||
| "type"
|
||||
| "stateKey"
|
||||
| "autocompleteVariables"
|
||||
| "autocompleteFunctions"
|
||||
| "autocomplete"
|
||||
| "extraExtensions"
|
||||
| "forcedEnvironmentId"
|
||||
> &
|
||||
Pick<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;
|
||||
}
|
||||
export type { PlainInputProps } from "@yaakapp-internal/ui";
|
||||
export { PlainInput } from "@yaakapp-internal/ui";
|
||||
@@ -1,148 +1,2 @@
|
||||
import type { ShowToastRequest } from "@yaakapp-internal/plugins";
|
||||
import { Icon, type IconProps, VStack } 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>
|
||||
);
|
||||
}
|
||||
export type { ToastProps } from "@yaakapp-internal/ui";
|
||||
export { Toast } from "@yaakapp-internal/ui";
|
||||
@@ -1,8 +1 @@
|
||||
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;
|
||||
}
|
||||
export { useRandomKey } from "@yaakapp-internal/ui";
|
||||
@@ -1,43 +1,9 @@
|
||||
import type { AnyModel } from "@yaakapp-internal/models";
|
||||
import { foldersAtom } from "@yaakapp-internal/models";
|
||||
import { resolvedModelName } from "@yaakapp-internal/ui/resolvedModelName";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { replaceTemplateTags } from "./templateTags";
|
||||
|
||||
export function resolvedModelName(r: AnyModel | null): string {
|
||||
if (r == null) return "";
|
||||
|
||||
if (!("url" in r) || r.model === "plugin") {
|
||||
return "name" in r ? r.name : "";
|
||||
}
|
||||
|
||||
// Return name if it has one
|
||||
if ("name" in r && r.name) {
|
||||
return r.name;
|
||||
}
|
||||
|
||||
// Replace variable syntax with variable name
|
||||
const withoutVariables = replaceTemplateTags(r.url, (m) => m.inner.trim());
|
||||
if (withoutVariables.trim() === "") {
|
||||
return r.model === "http_request"
|
||||
? r.bodyType && r.bodyType === "graphql"
|
||||
? "GraphQL Request"
|
||||
: "HTTP Request"
|
||||
: r.model === "websocket_request"
|
||||
? "WebSocket Request"
|
||||
: "gRPC Request";
|
||||
}
|
||||
|
||||
// GRPC gets nice short names
|
||||
if (r.model === "grpc_request" && r.service != null && r.method != null) {
|
||||
const shortService = r.service.split(".").pop();
|
||||
return `${shortService}/${r.method}`;
|
||||
}
|
||||
|
||||
// Strip unnecessary protocol
|
||||
const withoutProto = withoutVariables.replace(/^(http|https|ws|wss):\/\//, "");
|
||||
|
||||
return withoutProto;
|
||||
}
|
||||
export { resolvedModelName };
|
||||
|
||||
export function resolvedModelNameWithFolders(model: AnyModel | null): string {
|
||||
return resolvedModelNameWithFoldersArray(model).join(" / ");
|
||||
|
||||
@@ -1,137 +1,2 @@
|
||||
/*
|
||||
* Finding template tags in text.
|
||||
*
|
||||
* A tag closes at `]}`, but a quoted argument is allowed to contain `]}` of its own — the
|
||||
* printer in `crates/yaak-templates` emits `${[ fn(a='x]}y') ]}` verbatim — so the scan has to
|
||||
* step over quoted strings. It mirrors the Lezer grammar in
|
||||
* `components/core/Editor/twig/twig.grammar`: a terminated `'…'` (where `\` escapes whatever
|
||||
* follows it) is consumed whole, and an unterminated quote is just another character, so a
|
||||
* half-typed tag still closes at the next `]}` instead of swallowing the rest of the document.
|
||||
*
|
||||
* This was a regex until CodeQL flagged it (js/polynomial-redos): matching an unterminated
|
||||
* quote needed a negative lookahead that rescanned to the end of the input at every unpaired
|
||||
* quote, which is quadratic on text an attacker picks. The scan below is linear instead — see
|
||||
* the notes on `closingQuoteTable` and on the dead-end set in `findTemplateTags` for why.
|
||||
*/
|
||||
|
||||
const QUOTE = 0x27; // '
|
||||
const BACKSLASH = 0x5c; // \
|
||||
const CLOSE_BRACKET = 0x5d; // ]
|
||||
const CLOSE_BRACE = 0x7d; // }
|
||||
|
||||
/** The `${[`…`]}` bounds of one tag, and what sits between them. */
|
||||
export interface TemplateTagMatch {
|
||||
/** Index of the `$` that opens the tag. */
|
||||
start: number;
|
||||
/** Index just past the closing `]}`. */
|
||||
end: number;
|
||||
/** Everything between `${[` and `]}`, not trimmed. */
|
||||
inner: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* For every index `i`, the index of the `'` that closes a string whose body starts at `i`, or
|
||||
* `-1` when nothing does.
|
||||
*
|
||||
* Built right to left in a single pass, which is the trick that replaces the regex lookahead:
|
||||
* whether a quote opens a terminated string depends only on the text after it, so the answer
|
||||
* can be computed once for every position instead of re-derived at each quote. Skipping a
|
||||
* string of any length then costs one lookup.
|
||||
*/
|
||||
function closingQuoteTable(text: string): Int32Array {
|
||||
// Two extra slots so a `\` in the last position can read past the end and find `-1`
|
||||
const table = new Int32Array(text.length + 2).fill(-1);
|
||||
for (let i = text.length - 1; i >= 0; i--) {
|
||||
const c = text.charCodeAt(i);
|
||||
// A backslash escapes whatever follows it, including a quote
|
||||
const next = c === BACKSLASH ? table[i + 2] : table[i + 1];
|
||||
table[i] = c === QUOTE ? i : (next ?? -1);
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds every template tag in `text`, in order and without overlaps.
|
||||
*
|
||||
* A `${[` with no `]}` after it isn't a tag, and neither is a `${[` nested inside a tag body —
|
||||
* both are ordinary content, same as the regex this replaced treated them.
|
||||
*/
|
||||
export function findTemplateTags(text: string): TemplateTagMatch[] {
|
||||
const matches: TemplateTagMatch[] = [];
|
||||
|
||||
// Both built only once a scan actually needs them, so short values allocate nothing
|
||||
let closingQuotes: Int32Array | null = null;
|
||||
|
||||
/*
|
||||
* Body positions a previous scan already ran off the end of the text from. Where a scan ends
|
||||
* up depends only on where it starts, so a scan that walks into one of these would repeat
|
||||
* that same walk and fail the same way, and can stop right there.
|
||||
*
|
||||
* That bound is what keeps the whole pass linear: every position enters this set at most
|
||||
* once, and every step of every scan either visits a position for the first time or ends
|
||||
* that scan.
|
||||
*/
|
||||
let deadEnds: Set<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);
|
||||
}
|
||||
export type { TemplateTagMatch } from "@yaakapp-internal/ui/templateTags";
|
||||
export { findTemplateTags, replaceTemplateTags } from "@yaakapp-internal/ui/templateTags";
|
||||
@@ -1,50 +1,25 @@
|
||||
import { atom } from "jotai";
|
||||
import type { ToastInstance } from "../components/Toasts";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
// The toast store alone, without the components, which tests in a plain runtime can't load
|
||||
import {
|
||||
hideToast as hideToastIn,
|
||||
hideToastById as hideToastByIdIn,
|
||||
type ShowToastArgs,
|
||||
showToast as showToastIn,
|
||||
type ToastInstance,
|
||||
} from "@yaakapp-internal/ui/toast";
|
||||
import { jotaiStore } from "./jotai";
|
||||
|
||||
export const toastsAtom = atom<ToastInstance[]>([]);
|
||||
export { toastsAtom } from "@yaakapp-internal/ui/toast";
|
||||
|
||||
export function showToast({
|
||||
id,
|
||||
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 showToast(args: ShowToastArgs) {
|
||||
return showToastIn(jotaiStore, args);
|
||||
}
|
||||
|
||||
export function hideToastById(id: string) {
|
||||
const toast = jotaiStore.get(toastsAtom).find((t) => t.id === id);
|
||||
if (toast) hideToast(toast);
|
||||
hideToastByIdIn(jotaiStore, id);
|
||||
}
|
||||
|
||||
export function hideToast(toHide: ToastInstance) {
|
||||
jotaiStore.set(toastsAtom, (all) => {
|
||||
const t = all.find((t) => t.uniqueKey === toHide.uniqueKey);
|
||||
t?.onClose?.();
|
||||
return all.filter((t) => t.uniqueKey !== toHide.uniqueKey);
|
||||
});
|
||||
hideToastIn(jotaiStore, toHide);
|
||||
}
|
||||
|
||||
export function showErrorToast<T>({
|
||||
|
||||
@@ -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")} />
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
HStack,
|
||||
HttpStatusTagRaw,
|
||||
Icon,
|
||||
IconButton,
|
||||
SizeTag,
|
||||
type TabButtonRenderProps,
|
||||
type TabItem,
|
||||
@@ -17,11 +18,12 @@ import {
|
||||
Tabs,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { selectedFlowAtom } from "../lib/atoms";
|
||||
import { findHeader, mimeEssence } from "../lib/body";
|
||||
import { saveDialogFlowAtom, useFlowActions } from "../lib/flowActions";
|
||||
import { statusReason } from "../lib/statusReason";
|
||||
import { EmptyState, FlowBodyViewer, useFlowBody } from "./FlowBodyViewer";
|
||||
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">
|
||||
<span>Select a flow</span>
|
||||
<Keys keys={["↑", "↓"]} />
|
||||
<span>Start the proxy</span>
|
||||
<Keys keys={["⌘", "⇧", "P"]} />
|
||||
<span>Stop the proxy</span>
|
||||
<Keys keys={["⌘", "⇧", "S"]} />
|
||||
<span>Pause or resume recording</span>
|
||||
<Keys keys={["⌘", "R"]} />
|
||||
<span>Clear flows</span>
|
||||
<Keys keys={["⌘", "K"]} />
|
||||
</div>
|
||||
@@ -73,6 +73,8 @@ function FlowPane({ flow }: { flow: Flow }) {
|
||||
const noResponse = flow.error != null && flow.status == null;
|
||||
const mime = mimeEssence(findHeader(flow.responseHeaders, "content-type"));
|
||||
const queryCount = useMemo(() => queryParams(flow.url).length, [flow.url]);
|
||||
const openSave = useSetAtom(saveDialogFlowAtom);
|
||||
const flowActions = useFlowActions();
|
||||
|
||||
const 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={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",
|
||||
"overflow-x-auto font-mono text-sm text-text-subtle hide-scrollbars",
|
||||
)}
|
||||
@@ -172,6 +174,21 @@ function FlowPane({ flow }: { flow: Flow }) {
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<HStack space={1} className="font-sans ml-3">
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color={flow.savedRequestId == null ? "default" : "primary"}
|
||||
title={flow.savedRequestId == null ? undefined : "Saved to Yaak. Click to save again."}
|
||||
leftSlot={<Icon icon="bookmark" size="xs" />}
|
||||
onClick={() => openSave(flow)}
|
||||
>
|
||||
{flow.savedRequestId == null ? "Save to Yaak" : "Saved"}
|
||||
</Button>
|
||||
<Dropdown items={flowActions(flow).slice(2)}>
|
||||
<IconButton size="xs" icon="ellipsis" iconColor="secondary" title="Copy and export" />
|
||||
</Dropdown>
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden flex flex-col min-h-0">
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
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 { 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 { selectedFlowAtom, selectedFlowIdAtom } from "../lib/atoms";
|
||||
import { shortContentType } from "../lib/body";
|
||||
import { useFlowActions } from "../lib/flowActions";
|
||||
import { compareFlows } from "../lib/flowWindow";
|
||||
import { countNewFlows } from "../lib/follow";
|
||||
import { followAtom, loadOlderFlowsAtom, newestStartedAtAtom, oldestFlowAtom } from "../lib/store";
|
||||
import { filteredExchangesAtom } from "./Sidebar";
|
||||
import {
|
||||
activeQueryAtom,
|
||||
followAtom,
|
||||
hasAppsAtom,
|
||||
loadOlderFlowsAtom,
|
||||
newestStartedAtAtom,
|
||||
oldestFlowAtom,
|
||||
searchWindowAtom,
|
||||
shownFlowsAtom,
|
||||
} from "../lib/store";
|
||||
import { AppIcon } from "./AppIcon";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
|
||||
const ROW_HEIGHT = 24;
|
||||
const BOTTOM_PADDING = 6;
|
||||
@@ -29,7 +46,10 @@ interface Props {
|
||||
* the top loads older flows from the database.
|
||||
*/
|
||||
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 setSelectedFlow = useSetAtom(selectedFlowAtom);
|
||||
const loadOlder = useSetAtom(loadOlderFlowsAtom);
|
||||
@@ -153,17 +173,33 @@ export function FlowList({ sideBySide, style, className }: Props) {
|
||||
[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 columns = sideBySide
|
||||
? "grid-cols-[0.9rem_2.6rem_minmax(0,2fr)_minmax(0,3fr)_2.4rem_3.6rem]"
|
||||
: "grid-cols-[0.9rem_2.6rem_13rem_minmax(0,1fr)_2.4rem_5rem_4.5rem_3.8rem]";
|
||||
? hasApps
|
||||
? "grid-cols-[0.9rem_1rem_2.6rem_minmax(0,2fr)_minmax(0,3fr)_2.4rem_3.6rem]"
|
||||
: "grid-cols-[0.9rem_2.6rem_minmax(0,2fr)_minmax(0,3fr)_2.4rem_3.6rem]"
|
||||
: hasApps
|
||||
? "grid-cols-[0.9rem_8.5rem_2.6rem_13rem_minmax(0,1fr)_2.4rem_5rem_4.5rem_3.8rem]"
|
||||
: "grid-cols-[0.9rem_2.6rem_13rem_minmax(0,1fr)_2.4rem_5rem_4.5rem_3.8rem]";
|
||||
|
||||
return (
|
||||
<div
|
||||
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
|
||||
className={classNames(
|
||||
columns,
|
||||
@@ -172,6 +208,7 @@ export function FlowList({ sideBySide, style, className }: Props) {
|
||||
)}
|
||||
>
|
||||
<span />
|
||||
{hasApps && <span>{sideBySide ? "" : "App"}</span>}
|
||||
<span>Method</span>
|
||||
<span>Host</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"
|
||||
>
|
||||
{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() }}>
|
||||
{virtualizer.getVirtualItems().map((item) => {
|
||||
@@ -206,8 +243,10 @@ export function FlowList({ sideBySide, style, className }: Props) {
|
||||
start={item.start}
|
||||
columns={columns}
|
||||
sideBySide={sideBySide}
|
||||
showApp={hasApps}
|
||||
selected={flow.id === selectedId}
|
||||
onSelect={select}
|
||||
onContextMenu={openMenu}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -229,19 +268,38 @@ export function FlowList({ sideBySide, style, className }: Props) {
|
||||
{newCount === 1 ? "1 new flow" : `${newCount} new flows`}
|
||||
</button>
|
||||
)}
|
||||
<ContextMenu
|
||||
triggerPosition={menu}
|
||||
items={menu == null ? [] : flowActions(menu.flow)}
|
||||
onClose={() => setMenu(null)}
|
||||
/>
|
||||
</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 {
|
||||
flow: Flow;
|
||||
index: number;
|
||||
start: number;
|
||||
columns: string;
|
||||
sideBySide: boolean;
|
||||
showApp: boolean;
|
||||
selected: boolean;
|
||||
onSelect: (index: number) => void;
|
||||
onContextMenu: (index: number, e: MouseEvent) => void;
|
||||
}
|
||||
|
||||
const FlowRow = memo(function FlowRow({
|
||||
@@ -250,8 +308,10 @@ const FlowRow = memo(function FlowRow({
|
||||
start,
|
||||
columns,
|
||||
sideBySide,
|
||||
showApp,
|
||||
selected,
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
}: RowProps) {
|
||||
const { host, path, query } = splitUrl(flow.url);
|
||||
const failed = flow.error != null;
|
||||
@@ -263,7 +323,10 @@ const FlowRow = memo(function FlowRow({
|
||||
<div
|
||||
role="option"
|
||||
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)` }}
|
||||
className={classNames(
|
||||
columns,
|
||||
@@ -275,12 +338,24 @@ const FlowRow = memo(function FlowRow({
|
||||
)}
|
||||
>
|
||||
<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" />
|
||||
) : flow.responseTruncated ? (
|
||||
<Icon icon="scissors" size="xs" color="notice" title="Body cut at the capture limit" />
|
||||
) : null}
|
||||
</span>
|
||||
{showApp && (
|
||||
<span className="flex items-center gap-1.5" title={flow.clientPath ?? undefined}>
|
||||
{flow.clientPath != null && <AppIcon path={flow.clientPath} />}
|
||||
{!sideBySide && (
|
||||
<span className="truncate text-text-subtle">
|
||||
{flow.clientName ?? <span className="text-text-subtlest">--</span>}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<HttpMethodTagRaw method={flow.method} short colored />
|
||||
<span className="text-text-subtle">{host}</span>
|
||||
<span>
|
||||
|
||||
@@ -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 { useAtom, useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { flowLayoutAtom } from "../lib/atoms";
|
||||
import { searchWindowAtom, showFirstRunAtom, shownTotalAtom } from "../lib/store";
|
||||
import { getOsType } from "../lib/tauri";
|
||||
import { ClearFlowsButton } from "./ClearFlowsButton";
|
||||
import { FirstRun } from "./FirstRun";
|
||||
import { FlowDetail } from "./FlowDetail";
|
||||
import { FlowList } from "./FlowList";
|
||||
import { ProxyStatus } from "./ProxyStatus";
|
||||
import { filteredCountAtom, selectedSidebarLabelAtom, Sidebar } from "./Sidebar";
|
||||
import { SaveDialog } from "./SaveDialog";
|
||||
import { selectedSidebarLabelAtom, Sidebar } from "./Sidebar";
|
||||
import { TakeOverBanner } from "./TakeOverBanner";
|
||||
|
||||
export function ProxyLayout() {
|
||||
const os = getOsType();
|
||||
@@ -120,27 +131,45 @@ export function ProxyLayout() {
|
||||
)
|
||||
}
|
||||
>
|
||||
<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"} />
|
||||
)}
|
||||
/>
|
||||
<div className="h-full flex flex-col min-h-0">
|
||||
<TakeOverBanner />
|
||||
<div className="flex-1 min-h-0 grid grid-rows-[minmax(0,1fr)]">
|
||||
<FlowsOrFirstRun flowLayout={flowLayout} />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
<SaveDialog />
|
||||
<Toasts />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Its own component, so new traffic re-renders the count rather than the whole layout */
|
||||
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 (
|
||||
<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"} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +1,135 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { CaStatus } from "@yaakapp-internal/proxy-lib";
|
||||
import { Button, Dropdown, type DropdownItem, type DropdownRef, Icon } from "@yaakapp-internal/ui";
|
||||
import type { CaStatus, SystemProxyStatus } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
type DropdownItem,
|
||||
type DropdownRef,
|
||||
Icon,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue, useStore } from "jotai";
|
||||
import type { HTMLAttributes, ReactNode, Ref } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useRpcEvent } from "../hooks/useRpcEvent";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
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() {
|
||||
const queryClient = useQueryClient();
|
||||
const dropdownRef = useRef<DropdownRef>(null);
|
||||
const { data: proxyState } = useRpcQueryWithEvent("get_proxy_state", {}, "proxy_state_changed");
|
||||
const caStatus = useRpcQuery("ca_status", {});
|
||||
const trust = useRpcMutation("ca_trust", {
|
||||
onSuccess: (status) => queryClient.setQueryData<CaStatus>(["ca_status", {}], status),
|
||||
});
|
||||
const proxyState = useAtomValue(proxyStateAtom);
|
||||
const setup = useProxySetup();
|
||||
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 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 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[] = [
|
||||
{ type: "content", label: stateRow },
|
||||
{ type: "separator", hidden: systemProxy.data?.supported !== true },
|
||||
{
|
||||
type: "content",
|
||||
hidden: systemProxy.data?.supported !== true,
|
||||
label: (
|
||||
<MenuRow
|
||||
icon={<StateDot running={running} />}
|
||||
title={running && port != null ? `Running on port ${port}` : "Stopped"}
|
||||
icon={<Icon icon="wifi" size="sm" color="secondary" />}
|
||||
title="Override system proxy"
|
||||
description={
|
||||
running
|
||||
? "Listening on 127.0.0.1."
|
||||
: "Start the proxy to capture traffic from apps pointed at it."
|
||||
<span className={classNames(systemSwitch.failed && "text-danger")}>
|
||||
{systemSwitch.description}
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
<KeepMenuOpen>
|
||||
<Checkbox
|
||||
title="Override system proxy"
|
||||
hideLabel
|
||||
checked={systemSwitch.checked}
|
||||
// Only the process that owns the engine routes to it.
|
||||
disabled={!running || other != null || setSystemProxy.isPending}
|
||||
onChange={(enabled) => setSystemProxy.mutate({ enabled })}
|
||||
/>
|
||||
</KeepMenuOpen>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ type: "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",
|
||||
label: caStatus.data?.trusted ? (
|
||||
@@ -78,13 +170,17 @@ export function ProxyStatus() {
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: running ? "Stop Proxy" : "Start Proxy",
|
||||
icon: running ? "circle_stop" : "circle_play",
|
||||
hotKeyAction: running ? "proxy_stop" : "proxy_start",
|
||||
label: "Copy Environment for a Terminal",
|
||||
icon: "square_terminal",
|
||||
onSelect: copyEnvironment,
|
||||
},
|
||||
{
|
||||
label: recordingLabel(recording),
|
||||
icon: recording ? "circle_pause" : "circle_play",
|
||||
hidden: !running,
|
||||
hotKeyAction: "recording_toggle",
|
||||
hotKeyLabelOnly: true,
|
||||
onSelect: () => {
|
||||
execute.mutate({ scope: "global", action: running ? "proxy_stop" : "proxy_start" });
|
||||
},
|
||||
onSelect: () => execute.mutate({ scope: "global", action: "recording_toggle" }),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -97,17 +193,25 @@ export function ProxyStatus() {
|
||||
</Pill>
|
||||
)}
|
||||
<Dropdown ref={dropdownRef} items={items}>
|
||||
<Pill>
|
||||
{running ? (
|
||||
<Pill color={!running && other == null && proxyState != null ? "warning" : undefined}>
|
||||
{other != null ? (
|
||||
<>
|
||||
<StateDot running />
|
||||
<span className="text-text">Running</span>
|
||||
<StateDot state="cli" />
|
||||
<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>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<StateDot running={false} />
|
||||
Stopped
|
||||
<Icon icon="alert_triangle" size="xs" />
|
||||
Not running
|
||||
</>
|
||||
)}
|
||||
<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> {
|
||||
color?: "warning";
|
||||
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 (
|
||||
<span
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
"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;
|
||||
}) {
|
||||
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>
|
||||
<div>
|
||||
<div className="text-sm text-text">{title}</div>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -4,19 +4,22 @@ import { selectedIdsFamily, Tree } from "@yaakapp-internal/ui";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
import { useCallback } from "react";
|
||||
import { SIDEBAR_TREE_ID } from "../lib/atoms";
|
||||
import { appKeyOf, appNodeId, sidebarPath } from "../lib/search";
|
||||
import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse";
|
||||
import { flowsAtom, flowTotalAtom } from "../lib/store";
|
||||
import { 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 = {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Flows held under this node; for the Domains folder, every flow in the database */
|
||||
count: number;
|
||||
/** An app's path, for its icon */
|
||||
appPath?: string;
|
||||
};
|
||||
|
||||
export const SIDEBAR_TREE_ID = "proxy-sidebar";
|
||||
|
||||
const sidebarTreeAtom = atom<TreeNode<SidebarItem>>((get) => {
|
||||
const exchanges = get(flowsAtom);
|
||||
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 */
|
||||
export const selectedSidebarLabelAtom = atom((get) => {
|
||||
const selectedId = get(selectedIdsFamily(SIDEBAR_TREE_ID))[0];
|
||||
if (selectedId?.startsWith("domain:")) return selectedId.slice("domain:".length);
|
||||
if (selectedId?.startsWith("path:")) return selectedId.slice("path:".length);
|
||||
if (selectedId?.startsWith("app:")) {
|
||||
const apps = get(sidebarTreeAtom).children?.find((c) => c.item.id === "apps");
|
||||
return apps?.children?.find((c) => c.item.id === selectedId)?.item.label ?? "All traffic";
|
||||
}
|
||||
return "All traffic";
|
||||
});
|
||||
|
||||
@@ -83,21 +67,15 @@ const nodeIdsByFlow = new WeakMap<Flow, string[]>();
|
||||
function nodeIdsOf(ex: Flow): string[] {
|
||||
let ids = nodeIdsByFlow.get(ex);
|
||||
if (ids != null) return ids;
|
||||
let hostname: string;
|
||||
let segments: string[];
|
||||
try {
|
||||
const url = new URL(ex.url);
|
||||
hostname = url.host;
|
||||
segments = url.pathname.split("/").filter(Boolean);
|
||||
} catch {
|
||||
hostname = ex.url;
|
||||
segments = [];
|
||||
}
|
||||
// By the rules the database filters by, so a node shows the flows it counts.
|
||||
const path = sidebarPath(ex.url);
|
||||
const schemeless = !ex.url.includes("://");
|
||||
const [hostname = "", ...segments] = schemeless ? [path] : path.split("/");
|
||||
ids = [`domain:${hostname}`];
|
||||
let path = `path:${hostname}`;
|
||||
let node = `path:${hostname}`;
|
||||
for (const seg of segments) {
|
||||
path += `/${seg}`;
|
||||
ids.push(path);
|
||||
node += `/${seg}`;
|
||||
ids.push(node);
|
||||
}
|
||||
nodeIdsByFlow.set(ex, ids);
|
||||
return ids;
|
||||
@@ -197,6 +175,34 @@ function buildTree(exchanges: Flow[], total: number): TreeNode<SidebarItem> {
|
||||
domainsFolder.children?.push(toTreeNode(domain, domainsFolder, 2));
|
||||
}
|
||||
|
||||
// The apps flows came from, where the platform can tell; none on others, so no group.
|
||||
const apps = new Map<string, SidebarItem>();
|
||||
for (const ex of exchanges) {
|
||||
const key = appKeyOf(ex);
|
||||
if (key == null) continue;
|
||||
const id = appNodeId(key);
|
||||
const app = apps.get(id);
|
||||
if (app == null) {
|
||||
apps.set(id, { id, label: ex.clientName ?? key, count: 1, appPath: ex.clientPath ?? key });
|
||||
} else {
|
||||
app.count++;
|
||||
}
|
||||
}
|
||||
if (apps.size > 0) {
|
||||
const appsFolder: TreeNode<SidebarItem> = {
|
||||
item: { id: "apps", label: "Apps", count: 0 },
|
||||
parent: rootNode,
|
||||
depth: 1,
|
||||
children: [],
|
||||
draggable: false,
|
||||
};
|
||||
const sortedApps = [...apps.values()].sort((a, b) => collator.compare(a.label, b.label));
|
||||
for (const item of sortedApps) {
|
||||
appsFolder.children?.push({ item, parent: appsFolder, depth: 2, draggable: false });
|
||||
}
|
||||
rootNode.children?.push(appsFolder);
|
||||
}
|
||||
|
||||
rootNode.children?.push(domainsFolder);
|
||||
|
||||
return rootNode;
|
||||
@@ -206,6 +212,7 @@ function ItemInner({ item }: { item: SidebarItem }) {
|
||||
const count = item.count;
|
||||
return (
|
||||
<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>
|
||||
{count > 0 && <span className="ml-auto text-text-subtlest text-2xs shrink-0">{count}</span>}
|
||||
</div>
|
||||
@@ -214,9 +221,9 @@ function ItemInner({ item }: { item: SidebarItem }) {
|
||||
|
||||
export function Sidebar() {
|
||||
const tree = useAtomValue(sidebarTreeAtom);
|
||||
const treeId = SIDEBAR_TREE_ID;
|
||||
|
||||
const getItemKey = useCallback((item: SidebarItem) => `${item.id}:${item.count}`, []);
|
||||
const treeId = SIDEBAR_TREE_ID;
|
||||
|
||||
return (
|
||||
// The tree scrolls itself, in a row of bounded height, so its virtualizer mounts only the rows
|
||||
|
||||
@@ -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,6 +3,9 @@ import type { SplitLayoutLayout } from "@yaakapp-internal/ui";
|
||||
import { atom } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
|
||||
/** The sidebar's tree, whose selection narrows the list */
|
||||
export const SIDEBAR_TREE_ID = "proxy-sidebar";
|
||||
|
||||
/** The selected flow as last seen, kept once the window has dropped it so the detail stays */
|
||||
export const selectedFlowAtom = atom<Flow | null>(null);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 ")}`;
|
||||
}
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -1,6 +1,7 @@
|
||||
import type { ModelPayload } from "@yaakapp-internal/proxy-lib";
|
||||
import type { GetProxyStateResponse, ModelPayload } from "@yaakapp-internal/proxy-lib";
|
||||
import { selectedIdsFamily } from "@yaakapp-internal/ui";
|
||||
import { atom, type createStore } from "jotai";
|
||||
import { followingAtom, selectedFlowAtom } from "./atoms";
|
||||
import { followingAtom, SIDEBAR_TREE_ID, selectedFlowAtom } from "./atoms";
|
||||
import {
|
||||
applyChanges,
|
||||
emptyWindow,
|
||||
@@ -12,11 +13,18 @@ import {
|
||||
windowFromPage,
|
||||
} from "./flowWindow";
|
||||
import { listen, rpc } from "./rpc";
|
||||
import { type FlowQuery, matchesQuery, selectionQuery } from "./search";
|
||||
|
||||
type Store = ReturnType<typeof createStore>;
|
||||
|
||||
/** How long new flows gather before they reach the list, so a burst renders once */
|
||||
const FLUSH_MS = 100;
|
||||
/** How long after new flows the total is counted again from the database */
|
||||
const RECOUNT_MS = 1000;
|
||||
/** How often the database is read for new flows while another process owns the engine */
|
||||
const POLL_MS = 1000;
|
||||
/** How often the engine's owner is checked while it is another process */
|
||||
const OWNER_POLL_MS = 2000;
|
||||
|
||||
/** The flows in memory: a window onto the database, which keeps them all */
|
||||
export const flowWindowAtom = atom<FlowWindow>(emptyWindow);
|
||||
@@ -27,23 +35,73 @@ export const flowsAtom = atom((get) => get(flowWindowAtom).flows);
|
||||
/** Start of the newest flow held, across all traffic */
|
||||
export const newestStartedAtAtom = atom((get) => get(flowsAtom).at(-1)?.startedAt ?? null);
|
||||
|
||||
/** Start and id of the oldest flow held, which loading older flows moves back */
|
||||
export const oldestFlowAtom = atom((get) => get(flowsAtom)[0] ?? null);
|
||||
|
||||
export const hasOlderFlowsAtom = atom((get) => get(flowWindowAtom).hasOlder);
|
||||
|
||||
/** Flows in the database */
|
||||
export const flowTotalAtom = atom((get) => get(flowWindowAtom).total);
|
||||
|
||||
/** The first page of flows has loaded, so an empty window means an empty database */
|
||||
export const flowsLoadedAtom = atom(false);
|
||||
|
||||
/**
|
||||
* The database has no flows and nothing narrows the list, so the first-run card shows. A boolean
|
||||
* of its own, so new traffic does not re-render what reads it.
|
||||
*/
|
||||
export const showFirstRunAtom = atom(
|
||||
(get) => get(flowsLoadedAtom) && get(flowTotalAtom) === 0 && get(activeQueryAtom) == null,
|
||||
);
|
||||
|
||||
/** Some flow held names the app that sent it, which only some platforms can tell */
|
||||
export const hasAppsAtom = atom((get) => get(flowsAtom).some((f) => f.clientName != null));
|
||||
|
||||
/** The search box's terms, once typing settles, or null */
|
||||
export const searchTermsAtom = atom<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 */
|
||||
export const followAtom = atom(
|
||||
(get) => get(followingAtom),
|
||||
(_get, set, following: boolean) => {
|
||||
set(followingAtom, following);
|
||||
if (following) set(flowWindowAtom, (win) => trimWindow(win));
|
||||
if (!following) return;
|
||||
set(flowWindowAtom, (win) => trimWindow(win));
|
||||
set(searchWindowAtom, (s) => (s == null ? s : { ...s, win: trimWindow(s.win) }));
|
||||
},
|
||||
);
|
||||
|
||||
/** The engine's state, kept current from its events, and polled while another process has it */
|
||||
export const proxyStateAtom = atom<GetProxyStateResponse | null>(null);
|
||||
|
||||
/**
|
||||
* Changes applied while a page of older flows loads, or null when none is loading. A slow flow
|
||||
* that ends meanwhile may be older than the window, so left out of it, and missing from a page
|
||||
@@ -51,22 +109,29 @@ export const followAtom = atom(
|
||||
*/
|
||||
let changesDuringPage: ModelPayload[] | null = null;
|
||||
|
||||
/** Loads the page of flows before the oldest one held */
|
||||
/** Loads the page of flows before the oldest one shown, from the search's flows when searching */
|
||||
export const loadOlderFlowsAtom = atom(null, async (get, set) => {
|
||||
const { flows, hasOlder } = get(flowWindowAtom);
|
||||
const search = get(searchWindowAtom);
|
||||
const { flows, hasOlder } = search?.win ?? get(flowWindowAtom);
|
||||
const first = flows[0];
|
||||
if (changesDuringPage != null || !hasOlder || first == null) return;
|
||||
const changes: ModelPayload[] = [];
|
||||
changesDuringPage = changes;
|
||||
try {
|
||||
const res = await rpc("list_flows", { before: first.id, limit: PAGE_SIZE });
|
||||
// Dropped when the window moved on meanwhile, trimmed or cleared.
|
||||
set(flowWindowAtom, (win) => {
|
||||
const res = await rpc("list_flows", { ...search?.query, before: first.id, limit: PAGE_SIZE });
|
||||
// Dropped when the window moved on meanwhile, trimmed, cleared or searched again.
|
||||
const merge = (win: FlowWindow) => {
|
||||
if (win.flows[0]?.id !== first.id) return win;
|
||||
const merged = prependOlder(win, res.flows, PAGE_SIZE);
|
||||
const kept = search == null ? changes : changesFor(search.query, changes);
|
||||
// Counted when they first applied.
|
||||
return { ...applyChanges(merged, changes), total: merged.total };
|
||||
});
|
||||
return { ...applyChanges(merged, kept), total: merged.total };
|
||||
};
|
||||
if (search == null) {
|
||||
set(flowWindowAtom, merge);
|
||||
} else {
|
||||
set(searchWindowAtom, (s) => (s?.key === search.key ? { ...s, win: merge(s.win) } : s));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to load older flows", err);
|
||||
} finally {
|
||||
@@ -74,16 +139,26 @@ export const loadOlderFlowsAtom = atom(null, async (get, set) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** The changes a search's window takes: flows it matches, and every deletion */
|
||||
function changesFor(query: FlowQuery, changes: ModelPayload[]): ModelPayload[] {
|
||||
return changes.filter((c) => c.change.type === "delete" || matchesQuery(c.model, query));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the newest flows, then keeps them current with the engine's events. Events gather for
|
||||
* `FLUSH_MS` and apply together, and the window drops its oldest flows while the list follows,
|
||||
* so a new flow costs the same however many the database holds.
|
||||
* Loads the newest flows, then keeps them current with the engine's events, or with reads of the
|
||||
* database while another process owns the engine. Events gather for `FLUSH_MS` and apply
|
||||
* together, and the window drops its oldest flows while the list follows, so a new flow costs the
|
||||
* same however many the database holds. A search gets a window of its own, kept current the same
|
||||
* way.
|
||||
*/
|
||||
export function syncFlows(store: ReturnType<typeof createStore>) {
|
||||
export function syncFlows(store: Store) {
|
||||
let pending: ModelPayload[] = [];
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let loaded = false;
|
||||
let loads = 0;
|
||||
let searchLoads = 0;
|
||||
/** A flow written at or before the newest the app has seen, for reading new ones after it */
|
||||
let seenId: string | null = null;
|
||||
|
||||
function flush() {
|
||||
timer = null;
|
||||
@@ -91,9 +166,17 @@ export function syncFlows(store: ReturnType<typeof createStore>) {
|
||||
const changes = pending;
|
||||
pending = [];
|
||||
changesDuringPage?.push(...changes);
|
||||
const following = store.get(followingAtom);
|
||||
store.set(flowWindowAtom, (win) => {
|
||||
const next = applyChanges(win, changes);
|
||||
return store.get(followingAtom) ? trimWindow(next) : next;
|
||||
return following ? trimWindow(next) : next;
|
||||
});
|
||||
store.set(searchWindowAtom, (s) => {
|
||||
if (s == null) return s;
|
||||
// A matching flow written after the page's snapshot would otherwise be missed.
|
||||
if (s.loading) return { ...s, buffered: [...s.buffered, ...changes] };
|
||||
const next = applyChanges(s.win, changesFor(s.query, changes));
|
||||
return { ...s, win: following ? trimWindow(next) : next };
|
||||
});
|
||||
const selected = store.get(selectedFlowAtom);
|
||||
const change = changes.findLast((c) => c.model.id === selected?.id);
|
||||
@@ -103,6 +186,11 @@ export function syncFlows(store: ReturnType<typeof createStore>) {
|
||||
if (recountTimer == null) recountTimer = setTimeout(recount, RECOUNT_MS);
|
||||
}
|
||||
|
||||
function receive(changes: ModelPayload[]) {
|
||||
pending.push(...changes);
|
||||
if (timer == null) timer = setTimeout(flush, FLUSH_MS);
|
||||
}
|
||||
|
||||
// The total counts each new flow as it comes, which a flow both in a page and in the events
|
||||
// around it can count twice or not at all; a count from the database a moment later settles it.
|
||||
let recountTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -115,11 +203,19 @@ export function syncFlows(store: ReturnType<typeof createStore>) {
|
||||
win.total === res.total ? win : { ...win, total: res.total },
|
||||
);
|
||||
});
|
||||
const search = store.get(searchWindowAtom);
|
||||
if (search == null || search.loading) return;
|
||||
void rpc("list_flows", { ...search.query, limit: 0 }).then((res) => {
|
||||
const total = res.matching ?? res.total;
|
||||
store.set(searchWindowAtom, (s) =>
|
||||
s?.key !== search.key || s.win.total === total ? s : { ...s, win: { ...s.win, total } },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
listen("model_write", (payload) => {
|
||||
pending.push(payload);
|
||||
if (timer == null) timer = setTimeout(flush, FLUSH_MS);
|
||||
seenId = payload.model.id;
|
||||
receive([payload]);
|
||||
});
|
||||
|
||||
// Loads again rather than only emptying, for a flow written between the delete and the event.
|
||||
@@ -138,10 +234,123 @@ export function syncFlows(store: ReturnType<typeof createStore>) {
|
||||
// A clear since asked for a newer page.
|
||||
if (thisLoad !== loads) return;
|
||||
store.set(flowWindowAtom, windowFromPage(res.flows, WINDOW_SIZE, res.total));
|
||||
seenId = res.flows[0]?.id ?? null;
|
||||
loaded = true;
|
||||
store.set(flowsLoadedAtom, true);
|
||||
flush();
|
||||
});
|
||||
loadSearch(store.get(activeQueryAtom));
|
||||
}
|
||||
|
||||
/** Loads the first page of `query`'s flows into a window of its own, or drops it for null */
|
||||
function loadSearch(query: FlowQuery | null) {
|
||||
if (query == null) {
|
||||
store.set(searchWindowAtom, null);
|
||||
return;
|
||||
}
|
||||
// Unique to this load, so a slower answer to an earlier one with the same query, such as
|
||||
// one read before a clear, is dropped.
|
||||
const key = `${++searchLoads}:${JSON.stringify(query)}`;
|
||||
store.set(searchWindowAtom, { key, query, win: emptyWindow, loading: true, buffered: [] });
|
||||
const settle = (page: FlowWindow | null) =>
|
||||
store.set(searchWindowAtom, (s) => {
|
||||
if (s?.key !== key) return s;
|
||||
// Applied again over the page: a flow already in it is replaced, not added.
|
||||
const win = applyChanges(page ?? s.win, changesFor(query, s.buffered));
|
||||
return {
|
||||
...s,
|
||||
win: page == null ? win : { ...win, total: page.total },
|
||||
loading: false,
|
||||
buffered: [],
|
||||
};
|
||||
});
|
||||
void rpc("list_flows", { ...query, limit: WINDOW_SIZE })
|
||||
.then((res) => settle(windowFromPage(res.flows, WINDOW_SIZE, res.matching ?? res.total)))
|
||||
.catch((err) => {
|
||||
console.warn("Failed to search flows", err);
|
||||
settle(null);
|
||||
});
|
||||
}
|
||||
|
||||
let activeKey = JSON.stringify(store.get(activeQueryAtom));
|
||||
store.sub(activeQueryAtom, () => {
|
||||
const query = store.get(activeQueryAtom);
|
||||
const key = JSON.stringify(query);
|
||||
if (key === activeKey) return;
|
||||
activeKey = key;
|
||||
loadSearch(query);
|
||||
});
|
||||
|
||||
// While another process owns the engine no events come, so new flows are read from the
|
||||
// database. Reading after a flow that started last can return a slow one already seen, which
|
||||
// applies as an update; it never skips one.
|
||||
let polling = false;
|
||||
function readNewFlows() {
|
||||
if (polling || !loaded) return;
|
||||
polling = true;
|
||||
const after = seenId;
|
||||
const read =
|
||||
after == null
|
||||
? rpc("list_flows", { limit: WINDOW_SIZE })
|
||||
: rpc("list_flows", { after, limit: WINDOW_SIZE });
|
||||
read
|
||||
.then((res) => {
|
||||
if (res.flows.length === 0) return;
|
||||
// More arrived than one read holds: load the newest again rather than leave a gap.
|
||||
if (res.flows.length >= WINDOW_SIZE) {
|
||||
load();
|
||||
return;
|
||||
}
|
||||
if (seenId === after) seenId = res.flows[0]?.id ?? seenId;
|
||||
const known = new Set(store.get(flowsAtom).map((f) => f.id));
|
||||
receive(
|
||||
[...res.flows].reverse().map((model) => ({
|
||||
model,
|
||||
change: { type: "upsert", created: !known.has(model.id) },
|
||||
})),
|
||||
);
|
||||
})
|
||||
// The flow read after was cleared: start again from the newest.
|
||||
.catch(() => {
|
||||
seenId = null;
|
||||
load();
|
||||
})
|
||||
.finally(() => {
|
||||
polling = false;
|
||||
});
|
||||
}
|
||||
setInterval(() => {
|
||||
if (store.get(proxyStateAtom)?.otherOwner != null) readNewFlows();
|
||||
}, POLL_MS);
|
||||
// One last read once another owner lets go, for flows it wrote after the last one.
|
||||
let hadOtherOwner = store.get(proxyStateAtom)?.otherOwner != null;
|
||||
store.sub(proxyStateAtom, () => {
|
||||
const hasOtherOwner = store.get(proxyStateAtom)?.otherOwner != null;
|
||||
if (hadOtherOwner && !hasOtherOwner) readNewFlows();
|
||||
hadOtherOwner = hasOtherOwner;
|
||||
});
|
||||
|
||||
load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps `proxyStateAtom` current: on each state change, and polled while the engine is not
|
||||
* running here, so another process taking it or letting it go shows.
|
||||
*/
|
||||
export function syncProxyState(store: Store) {
|
||||
function refresh() {
|
||||
rpc("get_proxy_state", {})
|
||||
.then((state) => store.set(proxyStateAtom, state))
|
||||
.catch((err) => console.warn("Failed to read the proxy state", err));
|
||||
}
|
||||
listen("proxy_state_changed", refresh);
|
||||
setInterval(() => {
|
||||
if (store.get(proxyStateAtom)?.state !== "running") refresh();
|
||||
}, OWNER_POLL_MS);
|
||||
refresh();
|
||||
}
|
||||
|
||||
/** Reads the proxy state again, after an action that changes it without an event */
|
||||
export async function refreshProxyState(store: Store) {
|
||||
store.set(proxyStateAtom, await rpc("get_proxy_state", {}));
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,13 @@ import { type ReactNode, StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ProxyLayout } from "./components/ProxyLayout";
|
||||
import { useHotkeyContextValue } from "./lib/hotkeys";
|
||||
import { syncFlows } from "./lib/store";
|
||||
import { syncFlows, syncProxyState } from "./lib/store";
|
||||
import "./main.css";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const jotaiStore = createStore();
|
||||
|
||||
syncProxyState(jotaiStore);
|
||||
syncFlows(jotaiStore);
|
||||
|
||||
function Hotkeys({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"@tanstack/react-query": "^5.104.2",
|
||||
"@tanstack/react-virtual": "^3.14.17",
|
||||
"@tauri-apps/api": "^2.12.3",
|
||||
"@tauri-apps/plugin-dialog": "^2.8.2",
|
||||
"@tauri-apps/plugin-opener": "^2.7.1",
|
||||
"@tauri-apps/plugin-os": "^2.4.0",
|
||||
"@yaakapp-internal/proxy-lib": "^1.0.0",
|
||||
"@yaakapp-internal/theme": "^1.0.0",
|
||||
|
||||
@@ -17,7 +17,7 @@ include_dir = "0.7"
|
||||
r2d2 = "0.8.10"
|
||||
r2d2_sqlite = "0.32"
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
|
||||
rusqlite = { version = "0.38", features = ["bundled", "chrono", "functions"] }
|
||||
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
@@ -37,6 +37,27 @@ yaak-templates = { workspace = true }
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"objc2-core-foundation",
|
||||
"NSBitmapImageRep",
|
||||
"NSGraphics",
|
||||
"NSGraphicsContext",
|
||||
"NSImage",
|
||||
"NSImageRep",
|
||||
"NSWorkspace",
|
||||
] }
|
||||
objc2-foundation = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"NSData",
|
||||
"NSDictionary",
|
||||
"NSGeometry",
|
||||
"NSString",
|
||||
] }
|
||||
plist = "1.7"
|
||||
|
||||
[dev-dependencies]
|
||||
base64 = "0.23.1"
|
||||
bytes = "1"
|
||||
|
||||
+17
-1
@@ -44,7 +44,23 @@ elapsedMs: number | null, error: string | null,
|
||||
/**
|
||||
* The id of the Yaak request this flow was last saved as.
|
||||
*/
|
||||
savedRequestId: string | null, };
|
||||
savedRequestId: string | null,
|
||||
/**
|
||||
* The process that opened the client connection, where the platform can tell (macOS).
|
||||
*/
|
||||
clientPid: number | null,
|
||||
/**
|
||||
* Its app's name, or its executable's outside an app: "Google Chrome", "curl".
|
||||
*/
|
||||
clientName: string | null,
|
||||
/**
|
||||
* Its app's bundle id.
|
||||
*/
|
||||
clientBundleId: string | null,
|
||||
/**
|
||||
* Its app bundle's path, or its executable's outside an app.
|
||||
*/
|
||||
clientPath: string | null, };
|
||||
|
||||
export type FlowBody = "request" | "response";
|
||||
|
||||
|
||||
+153
-4
@@ -5,6 +5,12 @@ export type ActionInvocation = { "scope": "global", action: GlobalAction, };
|
||||
|
||||
export type ActionMetadata = { label: string, defaultHotkey: string | null, };
|
||||
|
||||
export type AppIconRequest = {
|
||||
/**
|
||||
* A flow's `clientPath`.
|
||||
*/
|
||||
path: string, };
|
||||
|
||||
export type CaStatus = {
|
||||
/**
|
||||
* macOS trusts this app's CA, so HTTPS flows can be decrypted.
|
||||
@@ -21,6 +27,37 @@ export type CaTrustRequest = Record<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,
|
||||
/**
|
||||
* JSONPath, or XPath for an XML or HTML body.
|
||||
@@ -39,10 +76,25 @@ export type FilterLanguage = "jsonpath" | "xpath";
|
||||
* Which flows a listing or a HAR export covers. Every field narrows it; none is all of them.
|
||||
*/
|
||||
export type FlowFilter = {
|
||||
/**
|
||||
* Search terms, any case, each of which must match the URL (host, path or query), the
|
||||
* method, the status (`404`, `40`, `4xx`) or the content type: `stripe post 4xx`.
|
||||
*/
|
||||
q?: string,
|
||||
/**
|
||||
* Part of the host, any case: `stripe` matches `api.stripe.com`.
|
||||
*/
|
||||
host?: string,
|
||||
/**
|
||||
* Only flows under one of these hosts or paths, as the app's sidebar names them:
|
||||
* `api.stripe.com` or `api.stripe.com/v1/charges`, with any port after the host.
|
||||
*/
|
||||
under?: Array<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.
|
||||
*/
|
||||
@@ -68,6 +120,12 @@ before?: string, };
|
||||
|
||||
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 GetFlowBodyRequest = { flowId: string, which: FlowBody, };
|
||||
@@ -92,9 +150,30 @@ export type GetProxyStateResponse = { state: ProxyState,
|
||||
/**
|
||||
* The port the engine is bound to, while it runs.
|
||||
*/
|
||||
port: number | null, };
|
||||
port: number | null,
|
||||
/**
|
||||
* The port the engine starts on.
|
||||
*/
|
||||
configuredPort: number,
|
||||
/**
|
||||
* Flows are stored. Paused, traffic still passes through, unrecorded.
|
||||
*/
|
||||
recording: boolean,
|
||||
/**
|
||||
* The system proxy turns on as the engine starts.
|
||||
*/
|
||||
systemProxyAtLaunch: boolean,
|
||||
/**
|
||||
* Why the engine last failed to start, until it starts.
|
||||
*/
|
||||
startError: string | null,
|
||||
/**
|
||||
* Another process that owns the engine, such as the CLI daemon. While one does, this app
|
||||
* cannot start its own, and new flows reach it only through the database.
|
||||
*/
|
||||
otherOwner: OtherOwner | null, };
|
||||
|
||||
export type GlobalAction = "proxy_start" | "proxy_stop" | "flows_clear";
|
||||
export type GlobalAction = "recording_toggle" | "flows_clear";
|
||||
|
||||
export type ListActionsRequest = Record<string, never>;
|
||||
|
||||
@@ -108,13 +187,33 @@ flows: Array<Flow>,
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export type Owner = "app" | "cli";
|
||||
|
||||
export type PreviewSaveRequest = { flowId: string, };
|
||||
|
||||
export type ProxySetting = { enabled: boolean, server: string, port: number, };
|
||||
|
||||
export type ProxyState = "running" | "stopped";
|
||||
@@ -123,7 +222,49 @@ export type ProxyStatePayload = { state: ProxyState, };
|
||||
|
||||
export type RpcEventSchema = { flows_cleared: FlowsClearedPayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, };
|
||||
|
||||
export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], };
|
||||
export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], save_flow: [SaveFlowToYaakRequest, SavedFlow], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], };
|
||||
|
||||
export type SaveFlowToYaakRequest = { flowId: string, workspaceId: string, folderId?: string,
|
||||
/**
|
||||
* None or blank leaves the request unnamed, and Yaak shows its URL.
|
||||
*/
|
||||
name?: string, };
|
||||
|
||||
/**
|
||||
* What saving a flow as a Yaak request writes, for the save dialog.
|
||||
*/
|
||||
export type SavePreview = { method: string, url: string,
|
||||
/**
|
||||
* The URL as saved, its query split out into parameters, which Yaak shows for an unnamed
|
||||
* request.
|
||||
*/
|
||||
requestUrl: string,
|
||||
/**
|
||||
* The saved body type, which Yaak shows for an unnamed GraphQL request with no URL.
|
||||
*/
|
||||
bodyType: string | null,
|
||||
/**
|
||||
* Headers Yaak sets itself or that only meant something to the proxy.
|
||||
*/
|
||||
droppedHeaders: Array<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 = {
|
||||
/**
|
||||
@@ -131,6 +272,10 @@ export type ServiceProxy = {
|
||||
*/
|
||||
name: string, http: ProxySetting, https: ProxySetting, };
|
||||
|
||||
export type SetRecordingRequest = { enabled: boolean, };
|
||||
|
||||
export type SetSystemProxyAtLaunchRequest = { enabled: boolean, };
|
||||
|
||||
/**
|
||||
* How a change gets administrator rights.
|
||||
*/
|
||||
@@ -153,3 +298,7 @@ port: number, services: Array<ServiceProxy>, backend: SystemProxyBackend,
|
||||
supported: boolean, };
|
||||
|
||||
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);
|
||||
@@ -5,8 +5,8 @@ use ts_rs::TS;
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub enum GlobalAction {
|
||||
ProxyStart,
|
||||
ProxyStop,
|
||||
/// Pauses or resumes storing flows.
|
||||
RecordingToggle,
|
||||
FlowsClear,
|
||||
}
|
||||
|
||||
@@ -33,17 +33,11 @@ fn default_hotkey(mac: &str, other: &str) -> Option<String> {
|
||||
pub fn all_global_actions() -> Vec<(ActionInvocation, ActionMetadata)> {
|
||||
vec![
|
||||
(
|
||||
ActionInvocation::Global { action: GlobalAction::ProxyStart },
|
||||
ActionInvocation::Global { action: GlobalAction::RecordingToggle },
|
||||
ActionMetadata {
|
||||
label: "Start Proxy".into(),
|
||||
default_hotkey: default_hotkey("Meta+Shift+P", "Ctrl+Shift+P"),
|
||||
},
|
||||
),
|
||||
(
|
||||
ActionInvocation::Global { action: GlobalAction::ProxyStop },
|
||||
ActionMetadata {
|
||||
label: "Stop Proxy".into(),
|
||||
default_hotkey: default_hotkey("Meta+Shift+S", "Ctrl+Shift+S"),
|
||||
label: "Pause or Resume Recording".into(),
|
||||
// Not Cmd+Shift+R, which reloads the window in development builds.
|
||||
default_hotkey: default_hotkey("Meta+R", "Ctrl+Shift+R"),
|
||||
},
|
||||
),
|
||||
(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,8 @@
|
||||
use crate::bodies::{self, DecodedBody};
|
||||
use crate::ca::CaStatus;
|
||||
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::flows::{self, FlowDetail, FlowFilter};
|
||||
@@ -127,6 +128,11 @@ impl ControlClient {
|
||||
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> {
|
||||
self.get("/system-proxy").await
|
||||
}
|
||||
|
||||
@@ -205,6 +205,8 @@ pub struct ControlStatus {
|
||||
pub proxy_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,
|
||||
pub flow_count: usize,
|
||||
pub ca: CaStatus,
|
||||
/// `None` when it could not be read.
|
||||
@@ -222,6 +224,11 @@ pub struct SystemProxyRequest {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecordingRequest {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClearResponse {
|
||||
/// How many flows were deleted.
|
||||
@@ -344,11 +351,14 @@ impl ControlServer {
|
||||
}
|
||||
|
||||
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::Cli => "the yaak CLI",
|
||||
};
|
||||
format!("The proxy is already running in {who} (pid {})", owner.pid)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ControlServer {
|
||||
@@ -472,6 +482,11 @@ async fn route(state: &ServerState, req: Request<Incoming>) -> Result<ControlRes
|
||||
let _ = state.stop_tx.send(());
|
||||
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"]) => {
|
||||
let filter = FlowFilter::from_query(&query).map_err(bad_request)?;
|
||||
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())
|
||||
.map_err(|e| format!("Failed to load proxy CA: {e}"))?;
|
||||
ca::trust(ca.cert_path()).await.map_err(bad_request)?;
|
||||
ctx.forget_passthrough();
|
||||
json(&ca::status(ca.cert_path()).await?)
|
||||
}
|
||||
(&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 },
|
||||
proxy_port,
|
||||
configured_port: ctx.settings()?.port,
|
||||
recording: ctx.recording(),
|
||||
flow_count: flows::count(&ctx.db)?,
|
||||
ca,
|
||||
system_proxy,
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::models::{Flow, find_header};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
use ts_rs::TS;
|
||||
use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter};
|
||||
|
||||
// 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.
|
||||
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")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct DroppedHeader {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
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")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub enum DropReason {
|
||||
/// Only meant for the hop between the app and the proxy.
|
||||
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 {
|
||||
name: default_name(&url),
|
||||
method: flow.method.clone(),
|
||||
url,
|
||||
url_parameters,
|
||||
@@ -228,16 +231,6 @@ fn graphql_body(flow: &Flow, text: &str) -> Option<BTreeMap<String, Value>> {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -287,7 +280,7 @@ mod tests {
|
||||
let r = &c.request;
|
||||
assert_eq!(r.method, "GET");
|
||||
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)> =
|
||||
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")]);
|
||||
@@ -391,7 +384,7 @@ mod tests {
|
||||
{ "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]
|
||||
@@ -412,7 +405,7 @@ mod tests {
|
||||
let c = flow_to_request(&f, &body("hello"));
|
||||
assert_eq!(c.request.body_type.as_deref(), Some("other"));
|
||||
assert_eq!(c.request.body["text"], "hello");
|
||||
assert_eq!(c.request.name, "x.test");
|
||||
assert_eq!(c.request.name, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use include_dir::{Dir, include_dir};
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use rusqlite::functions::FunctionFlags;
|
||||
use rusqlite::{OpenFlags, Transaction, TransactionBehavior};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
@@ -76,10 +77,23 @@ pub fn open_blobs_read_only(path: &Path) -> Result<BlobManager> {
|
||||
Ok(BlobManager::new(readers.clone(), readers))
|
||||
}
|
||||
|
||||
/// `sidebar_path(url)`, the app sidebar's place for a URL, for filtering by its nodes.
|
||||
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> {
|
||||
let manager = SqliteConnectionManager::file(path)
|
||||
.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()
|
||||
.max_size(2)
|
||||
.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| {
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
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()
|
||||
.max_size(max_size)
|
||||
@@ -117,10 +132,14 @@ mod tests {
|
||||
let path = dir.path().join("proxy.db");
|
||||
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();
|
||||
conn.execute_batch(
|
||||
"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');",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -130,6 +149,7 @@ mod tests {
|
||||
let flows = db.with_conn(|c| c.find_all::<Flow>()).unwrap();
|
||||
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].client_pid, flows[0].client_name.as_deref()), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -169,6 +189,22 @@ mod tests {
|
||||
|
||||
let flow_columns = columns(&conn, "flows");
|
||||
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:?}");
|
||||
|
||||
let journal_mode: String = conn.query_row("PRAGMA journal_mode", [], |r| r.get(0)).unwrap();
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::db::ProxyQueryManager;
|
||||
use crate::models::{Flow, FlowBody, FlowIden};
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use rusqlite::types::Value;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use yaak_database::UpsertModelInfo;
|
||||
@@ -13,9 +14,21 @@ use yaak_models::blob_manager::BlobManager;
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
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`.
|
||||
#[ts(optional)]
|
||||
pub host: Option<String>,
|
||||
/// Only flows under one of these hosts or paths, as the app's sidebar names them:
|
||||
/// `api.stripe.com` or `api.stripe.com/v1/charges`, with any port after the host.
|
||||
#[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.
|
||||
#[ts(optional)]
|
||||
pub method: Option<String>,
|
||||
@@ -36,14 +49,17 @@ pub struct FlowFilter {
|
||||
}
|
||||
|
||||
impl FlowFilter {
|
||||
/// The filter from a query string's `host`, `method`, `status`, `limit`, `after` and
|
||||
/// `before`.
|
||||
/// The filter from a query string's `q`, `host`, `under`, `app`, `method`, `status`,
|
||||
/// `limit`, `after` and `before`. `under` and `app` may repeat.
|
||||
pub fn from_query(query: &str) -> Result<Self, String> {
|
||||
let mut filter = Self::default();
|
||||
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
|
||||
let value = Some(value.into_owned()).filter(|v| !v.is_empty());
|
||||
match key.as_ref() {
|
||||
"q" => filter.q = 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,
|
||||
"status" => filter.status = value,
|
||||
"after" => filter.after = value,
|
||||
@@ -62,8 +78,17 @@ impl FlowFilter {
|
||||
pub fn to_query(&self) -> String {
|
||||
let mut query = url::form_urlencoded::Serializer::new(String::new());
|
||||
let limit = self.limit.map(|l| l.to_string());
|
||||
for (key, value) in [("q", &self.q), ("host", &self.host)] {
|
||||
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 [
|
||||
("host", &self.host),
|
||||
("method", &self.method),
|
||||
("status", &self.status),
|
||||
("limit", &limit),
|
||||
@@ -77,56 +102,175 @@ impl FlowFilter {
|
||||
query.finish()
|
||||
}
|
||||
|
||||
pub fn matches(&self, flow: &Flow) -> bool {
|
||||
if let Some(host) = &self.host
|
||||
&& !host_of(&flow.url).to_ascii_lowercase().contains(&host.to_ascii_lowercase())
|
||||
{
|
||||
return false;
|
||||
/// Narrows more than `after`, `before` and `limit` do, so it covers fewer than all flows.
|
||||
pub fn narrows(&self) -> bool {
|
||||
let given = |v: &Option<String>| v.as_deref().is_some_and(|v| !v.trim().is_empty());
|
||||
given(&self.q)
|
||||
|| 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
|
||||
&& !flow.method.eq_ignore_ascii_case(method)
|
||||
{
|
||||
return false;
|
||||
if let Some(host) = self.host.as_deref().filter(|h| !h.is_empty()) {
|
||||
sql.push_str(&format!(" AND instr(lower({HOST_SQL}), ?) > 0"));
|
||||
params.push(host.to_lowercase().into());
|
||||
}
|
||||
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) {
|
||||
None | Some("") => true,
|
||||
Some(s) if s.eq_ignore_ascii_case("error") => flow.error.is_some(),
|
||||
Some(s) => match (flow.status, s.to_ascii_lowercase().strip_suffix("xx")) {
|
||||
(Some(status), Some(class)) => class.parse() == Ok(status / 100),
|
||||
(Some(status), None) => s.parse() == Ok(status),
|
||||
(None, _) => false,
|
||||
None | Some("") => {}
|
||||
Some(s) if s.eq_ignore_ascii_case("error") => sql.push_str(" AND error IS NOT NULL"),
|
||||
// A whole status or a class, not the start of one as a search term may be.
|
||||
Some(s) if s.len() == 3 => match status_condition(&s.to_ascii_lowercase(), &mut params)
|
||||
{
|
||||
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.
|
||||
fn host_of(url: &str) -> &str {
|
||||
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
|
||||
rest.split(['/', '?', '#']).next().unwrap_or_default()
|
||||
/// The host and port of `url`, in SQL: what follows `://` up to the first `/`, `?` or `#`.
|
||||
const HOST_SQL: &str = "(SELECT CASE WHEN instr(r, '/') > 0 THEN substr(r, 1, instr(r, '/') - 1) \
|
||||
ELSE r END FROM (SELECT replace(replace(CASE WHEN instr(url, '://') > 0 \
|
||||
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
|
||||
/// table as the limit needs, so a page costs the same however many flows there are.
|
||||
/// A sidebar node as `sidebar_path` writes it, its host lowercased.
|
||||
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> {
|
||||
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
|
||||
/// takes in exactly the flows written before the page was read.
|
||||
pub fn page(db: &ProxyQueryManager, filter: &FlowFilter) -> Result<(Vec<Flow>, usize), String> {
|
||||
/// takes in exactly the flows written before the page was read. With a filter that narrows,
|
||||
/// 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| {
|
||||
let tx = c.conn().resolve().unchecked_transaction().map_err(|e| e.to_string())?;
|
||||
let flows = list_in(&tx, filter)?;
|
||||
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())?;
|
||||
Ok((flows, total))
|
||||
Ok((flows, total, matching))
|
||||
})
|
||||
}
|
||||
|
||||
fn list_in(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result<Vec<Flow>, String> {
|
||||
let mut sql = String::from("SELECT * FROM flows WHERE 1");
|
||||
let mut params: Vec<rusqlite::types::Value> = Vec::new();
|
||||
let (condition, mut params) = filter.condition();
|
||||
let mut sql = format!("SELECT * FROM flows WHERE {condition}");
|
||||
if let Some(id) = &filter.after {
|
||||
// By rowid, which only grows as flows are written.
|
||||
let rowid: i64 = conn
|
||||
@@ -136,7 +280,7 @@ fn list_in(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result<Vec<Flow>
|
||||
params.push(rowid.into());
|
||||
}
|
||||
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))
|
||||
.map_err(|_| format!("No flow {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());
|
||||
}
|
||||
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 rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(params), Flow::from_row)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut flows = Vec::new();
|
||||
for flow in rows {
|
||||
if flows.len() >= filter.limit.unwrap_or(usize::MAX) {
|
||||
break;
|
||||
}
|
||||
let flow = flow.map_err(|e| e.to_string())?;
|
||||
if filter.matches(&flow) {
|
||||
flows.push(flow);
|
||||
}
|
||||
}
|
||||
Ok(flows)
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn count_matching(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result<usize, String> {
|
||||
let (condition, params) = filter.condition();
|
||||
conn.query_row(
|
||||
&format!("SELECT COUNT(*) FROM flows WHERE {condition}"),
|
||||
rusqlite::params_from_iter(params),
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.map(|n| n as usize)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn filters() {
|
||||
let ok = flow("https://api.stripe.com:8443/v1/charges?x=1", "POST", Some(200), None);
|
||||
let missing = flow("http://example.com/missing", "GET", Some(404), None);
|
||||
let failed = flow("https://down.example.com/", "GET", None, Some("refused"));
|
||||
|
||||
let by = |filter: FlowFilter| {
|
||||
[&ok, &missing, &failed].iter().filter(|f| filter.matches(f)).count()
|
||||
};
|
||||
let (_dir, db) = db_of(vec![
|
||||
flow("https://api.stripe.com:8443/v1/charges?x=1", "POST", Some(200), None),
|
||||
flow("http://example.com/missing", "GET", Some(404), None),
|
||||
flow("https://down.example.com/", "GET", None, Some("refused")),
|
||||
]);
|
||||
let by = |filter: FlowFilter| urls(&db, filter).len();
|
||||
assert_eq!(by(FlowFilter::default()), 3);
|
||||
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("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 { status: Some("404".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("40".into()), ..Default::default() }), 0);
|
||||
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]
|
||||
fn after_follows_flows_as_they_are_recorded() {
|
||||
use yaak_database::UpdateSource;
|
||||
@@ -412,12 +753,19 @@ mod tests {
|
||||
#[test]
|
||||
fn query_round_trip() {
|
||||
let filter = FlowFilter {
|
||||
q: Some("post json".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()),
|
||||
limit: Some(20),
|
||||
..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("").unwrap(), FlowFilter::default());
|
||||
assert!(FlowFilter::from_query("limit=lots").is_err());
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod actions;
|
||||
pub mod attribution;
|
||||
pub mod bodies;
|
||||
pub mod ca;
|
||||
pub mod client;
|
||||
@@ -13,8 +14,11 @@ pub mod save;
|
||||
pub mod settings;
|
||||
|
||||
use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction};
|
||||
use crate::attribution::Attribution;
|
||||
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::macos::{SystemProxyBackend, SystemProxyStatus};
|
||||
use crate::models::{Flow, FlowBody, FlowIden, ModelPayload, ProxyHeader, find_header};
|
||||
@@ -26,10 +30,12 @@ use chrono::{DateTime, Utc};
|
||||
use log::{debug, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use ts_rs::TS;
|
||||
use yaak_database::{ModelChangeEvent, UpdateSource, UpsertModelInfo};
|
||||
use yaak_models::blob_manager::{BlobManager, BodyChunkWriter};
|
||||
@@ -46,10 +52,19 @@ pub struct ProxyCtx {
|
||||
owner: Owner,
|
||||
client_data_dir: PathBuf,
|
||||
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.
|
||||
system_proxy_turn: Arc<Mutex<()>>,
|
||||
/// The port the engine last ran on, which the system proxy may still point at.
|
||||
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 blobs: BlobManager,
|
||||
pub events: RpcEventEmitter,
|
||||
@@ -68,8 +83,12 @@ impl ProxyCtx {
|
||||
owner: Owner::App,
|
||||
client_data_dir: save::client_data_dir(),
|
||||
control: Arc::new(Mutex::new(None)),
|
||||
control_server: Arc::new(Mutex::new(None)),
|
||||
system_proxy_turn: Arc::new(Mutex::new(())),
|
||||
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,
|
||||
blobs,
|
||||
events,
|
||||
@@ -115,8 +134,26 @@ impl ProxyCtx {
|
||||
|
||||
/// 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
|
||||
/// 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> {
|
||||
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)
|
||||
&& owner.pid != std::process::id()
|
||||
{
|
||||
@@ -132,13 +169,30 @@ impl ProxyCtx {
|
||||
.map_err(|e| format!("Failed to load proxy CA: {e}"))?;
|
||||
let mut config = ProxyConfig::new(port.unwrap_or(settings.port), Arc::new(ca));
|
||||
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 attribution = Attribution::new(proxy_handle.port);
|
||||
let _ = attribution_slot.set(attribution.clone());
|
||||
|
||||
if let Some(event_rx) = proxy_handle.take_event_rx() {
|
||||
let db = self.db.clone();
|
||||
let blobs = self.blobs.clone();
|
||||
let events = self.events.clone();
|
||||
std::thread::spawn(move || run_event_loop(event_rx, db, blobs, events));
|
||||
let sink = FlowSink {
|
||||
db: self.db.clone(),
|
||||
blobs: self.blobs.clone(),
|
||||
events: self.events.clone(),
|
||||
attribution,
|
||||
recording: self.recording.clone(),
|
||||
};
|
||||
std::thread::spawn(move || run_event_loop(event_rx, sink));
|
||||
}
|
||||
|
||||
let bound = proxy_handle.port;
|
||||
@@ -148,13 +202,18 @@ impl ProxyCtx {
|
||||
if let Ok(mut last_port) = self.last_port.lock() {
|
||||
*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.
|
||||
let ctx = self.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = ctx.change_system_proxy(true, bound, true) {
|
||||
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 });
|
||||
@@ -163,6 +222,7 @@ impl ProxyCtx {
|
||||
|
||||
/// Stops the engine and turns off a system proxy that points at it.
|
||||
pub fn stop(&self) -> Result<(), String> {
|
||||
self.system_proxy_on.store(false, Ordering::SeqCst);
|
||||
let port = self.take_handle()?;
|
||||
if let Some(port) = port {
|
||||
let ctx = self.clone();
|
||||
@@ -218,6 +278,83 @@ impl ProxyCtx {
|
||||
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>) {
|
||||
if let Ok(mut control) = self.control.lock()
|
||||
&& let Some(control) = control.as_mut()
|
||||
@@ -240,23 +377,59 @@ impl ProxyCtx {
|
||||
macos::status(port, self.system_proxy_backend())
|
||||
}
|
||||
|
||||
/// Turns the system proxy on or off and remembers the choice. Turned on while the engine
|
||||
/// is stopped, it waits for the engine to start, so the Mac is never pointed at nothing.
|
||||
/// Blocks, through the password dialog.
|
||||
/// Points this Mac's web traffic at the running engine, or stops, at once, through the
|
||||
/// password dialog. Not saved: the `systemProxyAtLaunch` setting decides whether it turns
|
||||
/// on as the engine starts. Blocks.
|
||||
pub fn set_system_proxy(&self, enabled: bool) -> Result<SystemProxyStatus, String> {
|
||||
let mut settings = self.settings()?;
|
||||
let previous = settings.system_proxy;
|
||||
settings.system_proxy = enabled;
|
||||
settings.save(&self.settings_path())?;
|
||||
let port = self.port().unwrap_or(settings.port);
|
||||
let Some(port) = self.port() else {
|
||||
if enabled {
|
||||
return Err("The proxy is not running, so there is nothing to route to".into());
|
||||
}
|
||||
// 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) {
|
||||
settings.system_proxy = previous;
|
||||
let _ = settings.save(&self.settings_path());
|
||||
self.system_proxy_on.store(previous, Ordering::SeqCst);
|
||||
return Err(e);
|
||||
}
|
||||
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
|
||||
/// 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
|
||||
@@ -271,7 +444,8 @@ impl ProxyCtx {
|
||||
let _turn = self.system_proxy_turn.lock().map_err(|_| "lock poisoned".to_string())?;
|
||||
if follow_engine {
|
||||
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 {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -302,6 +476,11 @@ impl ProxyCtx {
|
||||
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.
|
||||
pub fn workspaces(&self) -> Result<Vec<WorkspaceSummary>, String> {
|
||||
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()
|
||||
}
|
||||
|
||||
// -- 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 --
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
@@ -391,6 +616,9 @@ pub struct ListFlowsResponse {
|
||||
/// Flows in the database, whatever the filter.
|
||||
#[ts(type = "number")]
|
||||
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)]
|
||||
@@ -412,6 +640,130 @@ pub struct GetProxyStateResponse {
|
||||
pub state: ProxyState,
|
||||
/// The port the engine is bound to, while it runs.
|
||||
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)]
|
||||
@@ -492,8 +844,7 @@ pub struct SystemProxySetRequest {
|
||||
fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool, RpcError> {
|
||||
match invocation {
|
||||
ActionInvocation::Global { action } => match action {
|
||||
GlobalAction::ProxyStart => ctx.start().map_err(|message| RpcError { message })?,
|
||||
GlobalAction::ProxyStop => ctx.stop().map_err(|message| RpcError { message })?,
|
||||
GlobalAction::RecordingToggle => ctx.set_recording(!ctx.recording()),
|
||||
GlobalAction::FlowsClear => {
|
||||
ctx.clear_flows().map_err(|message| RpcError { message })?;
|
||||
}
|
||||
@@ -502,13 +853,137 @@ fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool,
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn get_proxy_state(
|
||||
ctx: &ProxyCtx,
|
||||
async fn get_proxy_state(
|
||||
ctx: ProxyCtx,
|
||||
_req: GetProxyStateRequest,
|
||||
) -> Result<GetProxyStateResponse, RpcError> {
|
||||
let port = ctx.port();
|
||||
let state = if port.is_some() { ProxyState::Running } else { ProxyState::Stopped };
|
||||
Ok(GetProxyStateResponse { state, port })
|
||||
// The owner check takes a file lock and a connection, so off the async threads.
|
||||
blocking(move || {
|
||||
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> {
|
||||
@@ -573,6 +1048,7 @@ async fn ca_trust(ctx: ProxyCtx, _req: CaTrustRequest) -> Result<CaStatus, RpcEr
|
||||
let ca = CaStore::load_or_create(&ctx.ca_dir())
|
||||
.map_err(|e| rpc_error(format!("Failed to load proxy CA: {e}")))?;
|
||||
ca::trust(ca.cert_path()).await.map_err(rpc_error)?;
|
||||
ctx.forget_passthrough();
|
||||
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> {
|
||||
let (flows, total) = flows::page(&ctx.db, &req).map_err(rpc_error)?;
|
||||
Ok(ListFlowsResponse { flows, total })
|
||||
let (flows, total, matching) = flows::page(&ctx.db, &req).map_err(rpc_error)?;
|
||||
Ok(ListFlowsResponse { flows, total, matching })
|
||||
}
|
||||
|
||||
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 --
|
||||
|
||||
/// 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
|
||||
/// stream.
|
||||
struct InFlight {
|
||||
flow: Flow,
|
||||
/// The client connection it came on.
|
||||
connection: u64,
|
||||
request_body: Option<BodyChunkWriter>,
|
||||
response_body: Option<BodyChunkWriter>,
|
||||
}
|
||||
|
||||
fn run_event_loop(
|
||||
rx: std::sync::mpsc::Receiver<ProxyEvent>,
|
||||
/// Where the event loop stores flows, and whether it does.
|
||||
struct FlowSink {
|
||||
db: ProxyQueryManager,
|
||||
blobs: BlobManager,
|
||||
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();
|
||||
|
||||
while let Ok(event) = rx.recv() {
|
||||
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 request_body = Flow::body_id(&flow_id, FlowBody::Request);
|
||||
let response_body = Flow::body_id(&flow_id, FlowBody::Response);
|
||||
@@ -660,6 +1173,7 @@ fn run_event_loop(
|
||||
id,
|
||||
InFlight {
|
||||
flow,
|
||||
connection,
|
||||
request_body: Some(BodyChunkWriter::new(blobs.clone(), request_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.elapsed_ms = Some(clamp_ms(elapsed_ms));
|
||||
f.flow.error = error;
|
||||
finish_flow(&db, &events, f);
|
||||
finish_flow(&db, &events, &attribution, f);
|
||||
}
|
||||
}
|
||||
ProxyEvent::Error { id, error, elapsed_ms } => {
|
||||
if let Some(mut f) = in_flight.remove(&id) {
|
||||
f.flow.elapsed_ms = Some(clamp_ms(elapsed_ms));
|
||||
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.response_body);
|
||||
|
||||
let mut flow = f.flow;
|
||||
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 (saved, created) = ctx.upsert(&flow, &UpdateSource::Background)?;
|
||||
// Sent while this holds the writer, before the commit, so a clear's event, sent after
|
||||
@@ -777,18 +1302,26 @@ define_rpc! {
|
||||
commands {
|
||||
execute_action(ActionInvocation) -> bool,
|
||||
format_body(FormatBodyRequest) -> String,
|
||||
get_proxy_state(GetProxyStateRequest) -> GetProxyStateResponse,
|
||||
list_actions(ListActionsRequest) -> ListActionsResponse,
|
||||
list_flows(flows::FlowFilter) -> ListFlowsResponse,
|
||||
set_recording(SetRecordingRequest) -> bool,
|
||||
set_system_proxy_at_launch(SetSystemProxyAtLaunchRequest) -> bool,
|
||||
}
|
||||
async_commands {
|
||||
app_icon(AppIconRequest) -> Option<String>,
|
||||
ca_status(CaStatusRequest) -> CaStatus,
|
||||
ca_trust(CaTrustRequest) -> CaStatus,
|
||||
clear_flows(ClearFlowsRequest) -> usize,
|
||||
export_har(ExportHarRequest) -> usize,
|
||||
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) -> bool,
|
||||
}
|
||||
events {
|
||||
flows_cleared(FlowsClearedPayload),
|
||||
@@ -801,6 +1334,24 @@ define_rpc! {
|
||||
mod tests {
|
||||
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]
|
||||
async fn the_app_still_hears_why_a_body_failed_to_decode() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -55,6 +55,14 @@ pub struct Flow {
|
||||
pub error: Option<String>,
|
||||
/// The id of the Yaak request this flow was last saved as.
|
||||
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)]
|
||||
@@ -148,6 +156,10 @@ impl UpsertModelInfo for Flow {
|
||||
(ElapsedMs, self.elapsed_ms.into()),
|
||||
(Error, self.error.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::Error,
|
||||
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_ms: r.get("elapsed_ms")?,
|
||||
error: r.get("error")?,
|
||||
// Absent from a database read before its migrations have run.
|
||||
saved_request_id: match r.get("saved_request_id") {
|
||||
Err(rusqlite::Error::InvalidColumnName(_)) => None,
|
||||
v => v?,
|
||||
},
|
||||
saved_request_id: added_column(r, "saved_request_id")?,
|
||||
client_pid: added_column(r, "client_pid")?,
|
||||
client_name: added_column(r, "client_name")?,
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use crate::convert::{self, DroppedHeader};
|
||||
use crate::models::Flow;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use ts_rs::TS;
|
||||
use yaak_models::models::HttpRequest;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::UpdateSource;
|
||||
@@ -17,16 +18,18 @@ pub fn client_data_dir() -> PathBuf {
|
||||
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")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct WorkspaceSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub folders: Vec<FolderSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct FolderSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
@@ -39,7 +42,7 @@ pub struct FolderSummary {
|
||||
pub struct SaveFlowRequest {
|
||||
pub workspace_id: 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>,
|
||||
}
|
||||
|
||||
@@ -56,11 +59,16 @@ pub struct 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
|
||||
/// than creating a database for a Yaak that has never run.
|
||||
pub fn open(data_dir: &Path) -> Result<Self, String> {
|
||||
let db_path = data_dir.join("db.sqlite");
|
||||
if !db_path.exists() {
|
||||
if !Self::exists(data_dir) {
|
||||
return Err(format!(
|
||||
"Yaak's database was not found at {}. Install Yaak and open it once.",
|
||||
db_path.display()
|
||||
|
||||
@@ -7,15 +7,22 @@ use yaak_proxy::DEFAULT_CAPTURE_BODY_LIMIT;
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ProxySettings {
|
||||
pub port: u16,
|
||||
/// Whether the system proxy points at the engine while it runs.
|
||||
pub system_proxy: bool,
|
||||
/// Point this Mac's web traffic at the engine as it starts. It is turned off again as the
|
||||
/// 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.
|
||||
pub capture_body_limit: usize,
|
||||
}
|
||||
|
||||
impl Default for ProxySettings {
|
||||
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 settings = ProxySettings::load(&dir.path().join("settings.json")).unwrap();
|
||||
assert_eq!(settings.port, 9090);
|
||||
assert!(!settings.system_proxy);
|
||||
assert!(settings.system_proxy_at_launch);
|
||||
assert_eq!(settings.capture_body_limit, 10 * 1024 * 1024);
|
||||
}
|
||||
|
||||
@@ -63,7 +70,8 @@ mod tests {
|
||||
fn round_trip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
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();
|
||||
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();
|
||||
assert_eq!(
|
||||
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]
|
||||
fn invalid_json_is_an_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -10,6 +10,7 @@ use hyper::server::conn::http1;
|
||||
use hyper::service::service_fn;
|
||||
use hyper::{Request, Response};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
use yaak_models::models::{Folder, Workspace};
|
||||
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`.
|
||||
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();
|
||||
let (events, _rx) = RpcEventEmitter::new();
|
||||
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());
|
||||
let filter = FlowFilter { status: Some("4xx".into()), ..Default::default() };
|
||||
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();
|
||||
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();
|
||||
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() };
|
||||
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.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();
|
||||
}
|
||||
@@ -107,7 +107,7 @@ async fn wait_for_flow(ctx: &ProxyCtx, url: &str) -> Flow {
|
||||
async fn flow_is_stored_with_its_bodies() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
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();
|
||||
|
||||
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() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
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();
|
||||
|
||||
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() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
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();
|
||||
|
||||
let (events, _event_rx) = RpcEventEmitter::new();
|
||||
@@ -217,7 +217,18 @@ async fn body_rpcs() {
|
||||
let port = ctx.port().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 client = reqwest::Client::builder()
|
||||
|
||||
@@ -14,12 +14,14 @@ tauri-build = { version = "2.6.1", features = [] }
|
||||
|
||||
[features]
|
||||
default = ["wry"]
|
||||
wry = ["tauri/wry", "tauri/x11", "tauri/dbus"]
|
||||
wry = ["tauri/wry", "tauri/x11", "tauri/dbus", "tauri-plugin-dialog/gtk3"]
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tauri = { workspace = true, features = ["devtools"] }
|
||||
tauri-plugin-dialog = { workspace = true }
|
||||
tauri-plugin-opener = "2.5.4"
|
||||
tauri-plugin-os = "2.3.2"
|
||||
yaak-mac-window = { workspace = true }
|
||||
yaak-proxy-lib = { workspace = true }
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"windows": ["*"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:allow-save",
|
||||
"opener:allow-default-urls",
|
||||
"opener:allow-open-url",
|
||||
"os:allow-os-type",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-fullscreen",
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use log::{error, info, warn};
|
||||
use std::sync::Mutex;
|
||||
use tauri::Runtime;
|
||||
use tauri::{Emitter, Manager, RunEvent, State, WebviewWindow};
|
||||
use yaak_proxy_lib::ProxyCtx;
|
||||
use yaak_proxy_lib::control::{self, ControlServer};
|
||||
use yaak_rpc::{RpcEventEmitter, RpcRouter};
|
||||
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]
|
||||
async fn rpc(
|
||||
router: State<'_, RpcRouter<ProxyCtx>>,
|
||||
@@ -97,6 +61,8 @@ async fn rpc(
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(yaak_mac_window::init())
|
||||
.setup(|app| {
|
||||
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 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(yaak_proxy_lib::build_router());
|
||||
|
||||
@@ -126,10 +105,9 @@ pub fn run() {
|
||||
.run(|app_handle, event| {
|
||||
if let RunEvent::Exit = event {
|
||||
// Never leave the Mac pointed at a proxy that is gone.
|
||||
app_handle.state::<ProxyCtx>().shutdown();
|
||||
if let Ok(mut control) = app_handle.state::<Control>().0.lock() {
|
||||
control.take();
|
||||
}
|
||||
let ctx = app_handle.state::<ProxyCtx>();
|
||||
ctx.shutdown();
|
||||
ctx.stop_control_api();
|
||||
return;
|
||||
}
|
||||
if let RunEvent::Ready = event {
|
||||
|
||||
@@ -5,8 +5,8 @@ use hyper::service::service_fn;
|
||||
use hyper_util::rt::TokioIo;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
|
||||
use crate::Engine;
|
||||
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>
|
||||
where
|
||||
@@ -26,7 +26,8 @@ where
|
||||
|
||||
pub(crate) async fn handle_connection(
|
||||
stream: tokio::net::TcpStream,
|
||||
client: ClientConn,
|
||||
engine: Arc<Engine>,
|
||||
) -> 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
|
||||
}
|
||||
@@ -42,8 +42,15 @@ pub struct ProxyConfig {
|
||||
pub capture_body_limit: usize,
|
||||
/// Roots trusted for upstream TLS in addition to the platform's.
|
||||
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 {
|
||||
pub fn new(port: u16, ca: Arc<CaStore>) -> Self {
|
||||
Self {
|
||||
@@ -51,6 +58,7 @@ impl ProxyConfig {
|
||||
ca,
|
||||
capture_body_limit: DEFAULT_CAPTURE_BODY_LIMIT,
|
||||
upstream_root_certs: Vec::new(),
|
||||
on_accept: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,6 +77,10 @@ pub enum ProxyEvent {
|
||||
url: String,
|
||||
/// Version spoken by the client.
|
||||
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 {
|
||||
id: u64,
|
||||
@@ -149,6 +161,13 @@ impl ProxyHandle {
|
||||
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
|
||||
/// already is. For tests that must wait for the engine to record a failure.
|
||||
#[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 {
|
||||
ca: Arc<CaStore>,
|
||||
capture_body_limit: usize,
|
||||
events: std_mpsc::Sender<ProxyEvent>,
|
||||
next_id: AtomicU64,
|
||||
next_connection: AtomicU64,
|
||||
http_client: Client<HttpConnector, BoxBody>,
|
||||
upstream_tls: 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,
|
||||
events: event_tx,
|
||||
next_id: AtomicU64::new(1),
|
||||
next_connection: AtomicU64::new(1),
|
||||
http_client: Client::builder(TokioExecutor::new()).build_http(),
|
||||
upstream_tls: upstream_tls_config(&roots, &[b"h2", 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 on_accept = config.on_accept;
|
||||
let handle_engine = engine.clone();
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
let (ready_tx, ready_rx) = std_mpsc::channel();
|
||||
@@ -289,10 +318,19 @@ pub fn start_proxy(config: ProxyConfig) -> Result<ProxyHandle, String> {
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = listener.accept() => match result {
|
||||
Ok((stream, _)) => {
|
||||
Ok((stream, addr)) => {
|
||||
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 {
|
||||
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}");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ use tokio_rustls::{LazyConfigAcceptor, TlsConnector};
|
||||
use crate::body::{CaptureBody, Direction};
|
||||
use crate::connection::serve_http1;
|
||||
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>;
|
||||
type BoxError = Box<dyn Error + Send + Sync>;
|
||||
@@ -67,7 +67,12 @@ struct 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 };
|
||||
flow.engine.emit(ProxyEvent::RequestStart {
|
||||
id: flow.id,
|
||||
@@ -75,6 +80,8 @@ impl Flow {
|
||||
method: req.method().to_string(),
|
||||
url,
|
||||
http_version: version_str(req.version()),
|
||||
connection: client.connection,
|
||||
client_addr: client.addr,
|
||||
});
|
||||
for (name, value) in req.headers() {
|
||||
flow.engine.emit(ProxyEvent::RequestHeader {
|
||||
@@ -147,16 +154,17 @@ fn bad_gateway(body: String) -> Response<BoxBody> {
|
||||
|
||||
pub(crate) async fn handle_request(
|
||||
mut req: Request<Incoming>,
|
||||
client: ClientConn,
|
||||
engine: Arc<Engine>,
|
||||
) -> Result<Response<BoxBody>, Infallible> {
|
||||
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.
|
||||
let from_host = req.uri().authority().is_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 {
|
||||
return Ok(flow.reject(status, error));
|
||||
}
|
||||
@@ -220,7 +228,11 @@ struct Target {
|
||||
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 {
|
||||
return Response::builder()
|
||||
.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}") },
|
||||
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()));
|
||||
}
|
||||
});
|
||||
@@ -248,7 +260,12 @@ fn handle_connect(req: Request<Incoming>, engine: Arc<Engine>) -> Response<BoxBo
|
||||
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
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
@@ -308,7 +325,7 @@ where
|
||||
|
||||
let tls = Rewind::new(Bytes::from(buf), tls);
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -395,11 +412,12 @@ async fn connect_upstream(
|
||||
async fn forward_https(
|
||||
mut req: Request<Incoming>,
|
||||
target: Arc<Target>,
|
||||
client: ClientConn,
|
||||
engine: Arc<Engine>,
|
||||
) -> Result<Response<BoxBody>, Infallible> {
|
||||
let path = req.uri().path_and_query().map(|pq| pq.to_string()).unwrap_or_else(|| "/".into());
|
||||
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> =
|
||||
req.headers().contains_key(UPGRADE).then(|| hyper::upgrade::on(&mut req));
|
||||
|
||||
@@ -352,12 +352,15 @@ async fn https_get() {
|
||||
assert_eq!(resp.text().await.unwrap(), "hello");
|
||||
|
||||
let events = h.flow_events();
|
||||
let (method, req_url, req_version, started_at) = find!(events,
|
||||
ProxyEvent::RequestStart { method, url, http_version, started_at, .. } =>
|
||||
(method.clone(), url.clone(), http_version.clone(), *started_at));
|
||||
let (method, req_url, req_version, started_at, connection, client_addr) = find!(events,
|
||||
ProxyEvent::RequestStart { method, url, http_version, started_at, connection, client_addr, .. } =>
|
||||
(method.clone(), url.clone(), http_version.clone(), *started_at, *connection, *client_addr));
|
||||
assert_eq!(method, "GET");
|
||||
assert_eq!(req_url, url);
|
||||
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));
|
||||
|
||||
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)
|
||||
.await
|
||||
.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")]
|
||||
|
||||
Generated
+2
@@ -248,6 +248,8 @@
|
||||
"@tanstack/react-query": "^5.104.2",
|
||||
"@tanstack/react-virtual": "^3.14.17",
|
||||
"@tauri-apps/api": "^2.12.3",
|
||||
"@tauri-apps/plugin-dialog": "^2.8.2",
|
||||
"@tauri-apps/plugin-opener": "^2.7.1",
|
||||
"@tauri-apps/plugin-os": "^2.4.0",
|
||||
"@yaakapp-internal/proxy-lib": "^1.0.0",
|
||||
"@yaakapp-internal/theme": "^1.0.0",
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./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": [
|
||||
"*.css"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ArrowUpFromLineIcon,
|
||||
ArrowUpIcon,
|
||||
BadgeCheckIcon,
|
||||
BookmarkIcon,
|
||||
BookOpenText,
|
||||
BoxIcon,
|
||||
CakeIcon,
|
||||
@@ -166,6 +167,7 @@ const icons = {
|
||||
arrow_up_from_dot: ArrowUpFromDotIcon,
|
||||
arrow_up_from_line: ArrowUpFromLineIcon,
|
||||
badge_check: BadgeCheckIcon,
|
||||
bookmark: BookmarkIcon,
|
||||
book_open_text: BookOpenText,
|
||||
box: BoxIcon,
|
||||
cake: CakeIcon,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,11 +2,15 @@ export type { BannerProps } from "./components/Banner";
|
||||
export { Banner } from "./components/Banner";
|
||||
export type { ButtonProps } 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 { CopyIconButton } from "./components/CopyIconButton";
|
||||
export { CountBadge } from "./components/CountBadge";
|
||||
export type { DetailsBannerProps } 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 type {
|
||||
ContextMenuProps,
|
||||
@@ -40,6 +44,8 @@ export { KeyValueRow, KeyValueRows } from "./components/KeyValueRow";
|
||||
export { Label } from "./components/Label";
|
||||
export { LoadingIcon } from "./components/LoadingIcon";
|
||||
export { Overlay } from "./components/Overlay";
|
||||
export type { PlainInputProps } from "./components/PlainInput";
|
||||
export { PlainInput } from "./components/PlainInput";
|
||||
export { Portal } from "./components/Portal";
|
||||
export type { SeparatorAction } from "./components/Separator";
|
||||
export { Separator } from "./components/Separator";
|
||||
@@ -68,6 +74,9 @@ export type {
|
||||
TabsStorage,
|
||||
} 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 { formatMillis, HttpResponseDurationTag } from "./components/tags/HttpResponseDurationTag";
|
||||
export { HttpStatusTag, HttpStatusTagRaw } from "./components/tags/HttpStatusTag";
|
||||
@@ -104,6 +113,7 @@ export type {
|
||||
} from "./hooks/useHotKey";
|
||||
export { formatHotkey, HotkeyProvider, useFormattedHotkey, useHotKey } from "./hooks/useHotKey";
|
||||
export { useIsFullscreen } from "./hooks/useIsFullscreen";
|
||||
export { useRandomKey } from "./hooks/useRandomKey";
|
||||
export { usePortal } from "./hooks/usePortal";
|
||||
export { useStateWithDeps } from "./hooks/useStateWithDeps";
|
||||
export type { SetStoredValue, StoredValue, UseStoredValue } from "./hooks/useStoredValue";
|
||||
@@ -118,3 +128,5 @@ export { computeSideForDragMove } from "./lib/dnd";
|
||||
export { docFingerprint } from "./lib/docFingerprint";
|
||||
export { getNodeText } from "./lib/getNodeText";
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user