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",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-os",
|
"tauri-plugin-os",
|
||||||
"yaak-mac-window",
|
"yaak-mac-window",
|
||||||
"yaak-proxy-lib",
|
"yaak-proxy-lib",
|
||||||
@@ -11765,6 +11767,10 @@ dependencies = [
|
|||||||
"include_dir",
|
"include_dir",
|
||||||
"libc",
|
"libc",
|
||||||
"log 0.4.34",
|
"log 0.4.34",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"plist",
|
||||||
"r2d2",
|
"r2d2",
|
||||||
"r2d2_sqlite",
|
"r2d2_sqlite",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function ErrorBoundary({ name, children }: { name: string; children: Reac
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showErrorDialog(error: unknown) {
|
export function showErrorDialog(error: unknown) {
|
||||||
showDialog({ id: "error-boundary", render: () => <RouteError error={error} /> });
|
showDialog({ id: "error-boundary", render: () => <RouteError error={error} /> });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +1,6 @@
|
|||||||
import { useAtomValue } from "jotai";
|
import { Toasts as BaseToasts } from "@yaakapp-internal/ui";
|
||||||
import { AnimatePresence } from "motion/react";
|
import { showErrorDialog } from "./ErrorBoundary";
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { hideToast, toastsAtom } from "../lib/toast";
|
|
||||||
import { Toast, type ToastProps } from "./core/Toast";
|
|
||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
|
||||||
import { Portal } from "@yaakapp-internal/ui";
|
|
||||||
|
|
||||||
export type ToastInstance = {
|
export type { ToastInstance } from "@yaakapp-internal/ui";
|
||||||
id: string;
|
|
||||||
uniqueKey: string;
|
|
||||||
message: ReactNode;
|
|
||||||
timeout: 3000 | 5000 | 8000 | (number & {}) | null;
|
|
||||||
onClose?: ToastProps["onClose"];
|
|
||||||
} & Omit<ToastProps, "onClose" | "open" | "children" | "timeout">;
|
|
||||||
|
|
||||||
export const Toasts = () => {
|
export const Toasts = () => <BaseToasts onShowError={showErrorDialog} />;
|
||||||
const toasts = useAtomValue(toastsAtom);
|
|
||||||
return (
|
|
||||||
<Portal name="toasts">
|
|
||||||
<div className="absolute right-0 bottom-0 z-50">
|
|
||||||
<AnimatePresence>
|
|
||||||
{toasts.map((toast: ToastInstance) => {
|
|
||||||
const { message, uniqueKey, ...props } = toast;
|
|
||||||
return (
|
|
||||||
<ErrorBoundary key={uniqueKey} name={`Toast ${uniqueKey}`}>
|
|
||||||
<Toast
|
|
||||||
open
|
|
||||||
{...props}
|
|
||||||
// We call onClose inside actions.hide instead of passing to toast so that
|
|
||||||
// it gets called from external close calls as well
|
|
||||||
onClose={() => hideToast(toast)}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</Toast>
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,72 +1,2 @@
|
|||||||
import { HStack, Icon, IconTooltip } from "@yaakapp-internal/ui";
|
export type { CheckboxProps } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
export { Checkbox } from "@yaakapp-internal/ui";
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
export interface CheckboxProps {
|
|
||||||
checked: boolean | "indeterminate";
|
|
||||||
title: ReactNode;
|
|
||||||
onChange: (checked: boolean) => void;
|
|
||||||
className?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
inputWrapperClassName?: string;
|
|
||||||
hideLabel?: boolean;
|
|
||||||
fullWidth?: boolean;
|
|
||||||
help?: ReactNode;
|
|
||||||
size?: "sm" | "md";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Checkbox({
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
className,
|
|
||||||
inputWrapperClassName,
|
|
||||||
disabled,
|
|
||||||
title,
|
|
||||||
hideLabel,
|
|
||||||
fullWidth,
|
|
||||||
help,
|
|
||||||
size = "sm",
|
|
||||||
}: CheckboxProps) {
|
|
||||||
return (
|
|
||||||
<HStack
|
|
||||||
as="label"
|
|
||||||
alignItems="center"
|
|
||||||
space={2}
|
|
||||||
className={classNames(className, "text-text mr-auto")}
|
|
||||||
>
|
|
||||||
<div className={classNames(inputWrapperClassName, "x-theme-input", "relative flex mr-0.5")}>
|
|
||||||
<input
|
|
||||||
aria-hidden
|
|
||||||
className={classNames(
|
|
||||||
"appearance-none shrink-0 border border-border",
|
|
||||||
size === "sm" && "w-4 h-4",
|
|
||||||
size === "md" && "w-5 h-5",
|
|
||||||
"rounded-sm outline-hidden ring-0",
|
|
||||||
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
|
|
||||||
disabled && "border-dotted",
|
|
||||||
)}
|
|
||||||
type="checkbox"
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={() => {
|
|
||||||
onChange(checked === "indeterminate" ? true : !checked);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<Icon
|
|
||||||
size={size}
|
|
||||||
className={classNames(disabled && "opacity-disabled")}
|
|
||||||
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!hideLabel && (
|
|
||||||
<div
|
|
||||||
className={classNames("text-sm", fullWidth && "w-full", disabled && "opacity-disabled")}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{help && <IconTooltip content={help} />}
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,200 +1,2 @@
|
|||||||
import type { DialogSize } from "@yaakapp-internal/plugins";
|
export type { DialogFooterAction, DialogProps } from "@yaakapp-internal/ui";
|
||||||
import { Heading, HStack, Overlay } from "@yaakapp-internal/ui";
|
export { Dialog, DialogFooter } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
|
||||||
import * as m from "motion/react-m";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { createContext, useContext, useMemo, useState } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { Button, type ButtonProps } from "./Button";
|
|
||||||
import { IconButton } from "./IconButton";
|
|
||||||
|
|
||||||
export interface DialogProps {
|
|
||||||
children: ReactNode;
|
|
||||||
open: boolean;
|
|
||||||
onClose?: () => void;
|
|
||||||
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
|
|
||||||
disableClose?: boolean;
|
|
||||||
title?: ReactNode;
|
|
||||||
description?: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
size?: DialogSize;
|
|
||||||
/** Hide the built-in close button without changing backdrop or Escape behavior. */
|
|
||||||
hideX?: boolean;
|
|
||||||
noPadding?: boolean;
|
|
||||||
noScroll?: boolean;
|
|
||||||
vAlign?: "top" | "center";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Dialog({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
size = "full",
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
disableClose,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
hideX,
|
|
||||||
noPadding,
|
|
||||||
noScroll,
|
|
||||||
vAlign = "center",
|
|
||||||
}: DialogProps) {
|
|
||||||
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
|
|
||||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
|
||||||
const descriptionId = useMemo(
|
|
||||||
() => (description ? Math.random().toString(36).slice(2) : undefined),
|
|
||||||
[description],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
|
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
className={classNames(
|
|
||||||
"py-4 x-theme-dialog absolute inset-0 pointer-events-none",
|
|
||||||
"h-full flex flex-col items-center justify-center",
|
|
||||||
vAlign === "top" && "justify-start",
|
|
||||||
vAlign === "center" && "justify-center",
|
|
||||||
)}
|
|
||||||
aria-labelledby={titleId}
|
|
||||||
aria-describedby={descriptionId}
|
|
||||||
tabIndex={-1}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
|
|
||||||
// dialogs and can be intercepted by children if needed.
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
if (!disableClose) onClose?.();
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<m.div
|
|
||||||
initial={{ top: 5, scale: 0.97 }}
|
|
||||||
animate={{ top: 0, scale: 1 }}
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"grid grid-rows-[auto_auto_minmax(0,1fr)_auto]",
|
|
||||||
"grid-cols-1", // must be here for inline code blocks to correctly break words
|
|
||||||
"relative bg-surface pointer-events-auto",
|
|
||||||
"rounded-lg",
|
|
||||||
"border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]",
|
|
||||||
"min-h-40",
|
|
||||||
"max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]",
|
|
||||||
size === "sm" && "w-120",
|
|
||||||
size === "md" && "w-200",
|
|
||||||
size === "lg" && "w-280",
|
|
||||||
size === "full" && "w-screen h-screen",
|
|
||||||
size === "dynamic" && "min-w-80 max-w-[100vw]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{title ? (
|
|
||||||
<Heading className="px-6 mt-4 mb-2" level={1} id={titleId}>
|
|
||||||
{title}
|
|
||||||
</Heading>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{description ? (
|
|
||||||
<div className="min-h-0 px-6 text-text-subtle mb-3" id={descriptionId}>
|
|
||||||
{description}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"h-full w-full grid grid-cols-[minmax(0,1fr)] grid-rows-1",
|
|
||||||
!noPadding && "px-6 py-2",
|
|
||||||
!noScroll && "overflow-y-auto overflow-x-hidden",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<DialogFooterSlot.Provider value={footerEl}>{children}</DialogFooterSlot.Provider>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref={setFooterEl} className="contents" />
|
|
||||||
|
|
||||||
{/*Put close at the end so that it's the last thing to be tabbed to*/}
|
|
||||||
{!disableClose && !hideX && (
|
|
||||||
<div className="ml-auto absolute right-1 top-1">
|
|
||||||
<IconButton
|
|
||||||
className="opacity-70 hover:opacity-100"
|
|
||||||
onClick={onClose}
|
|
||||||
title="Close dialog (Esc)"
|
|
||||||
aria-label="Close"
|
|
||||||
size="sm"
|
|
||||||
icon="x"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</m.div>
|
|
||||||
</div>
|
|
||||||
</Overlay>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const DialogFooterSlot = createContext<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
export interface DialogFooterAction {
|
|
||||||
label: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
/** Submit the form with this id, which can be anywhere in the dialog body */
|
|
||||||
form?: string;
|
|
||||||
color?: ButtonProps["color"];
|
|
||||||
variant?: ButtonProps["variant"];
|
|
||||||
disabled?: boolean;
|
|
||||||
isLoading?: boolean;
|
|
||||||
autoFocus?: boolean;
|
|
||||||
leftSlot?: ReactNode;
|
|
||||||
rightSlot?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The action row at the bottom of a dialog. Render it anywhere in the dialog's content and it is
|
|
||||||
* placed below the scrolling body, outside its padding. `inline` drops the divider for small
|
|
||||||
* dialogs whose body never scrolls.
|
|
||||||
*/
|
|
||||||
export function DialogFooter({
|
|
||||||
actions,
|
|
||||||
leftSlot,
|
|
||||||
inline,
|
|
||||||
}: {
|
|
||||||
actions: DialogFooterAction[];
|
|
||||||
leftSlot?: ReactNode;
|
|
||||||
inline?: boolean;
|
|
||||||
}) {
|
|
||||||
const slot = useContext(DialogFooterSlot);
|
|
||||||
const footer = (
|
|
||||||
<footer
|
|
||||||
className={classNames(
|
|
||||||
"px-6 flex items-center gap-3",
|
|
||||||
inline ? "pt-1 pb-4" : "py-3 border-t border-border-subtle",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{leftSlot != null && <div className="mr-auto min-w-0">{leftSlot}</div>}
|
|
||||||
<HStack space={2} justifyContent="end" className="ml-auto shrink-0">
|
|
||||||
{actions.map((action, i) => (
|
|
||||||
<Button
|
|
||||||
// Labels change with state ("Cloning...", "Apply 3 Changes"), so they can't be keys
|
|
||||||
key={i}
|
|
||||||
type={action.form != null ? "submit" : "button"}
|
|
||||||
form={action.form}
|
|
||||||
color={action.color ?? "secondary"}
|
|
||||||
variant={action.variant ?? (action.color == null ? "border" : "solid")}
|
|
||||||
disabled={action.disabled}
|
|
||||||
isLoading={action.isLoading}
|
|
||||||
autoFocus={action.autoFocus}
|
|
||||||
leftSlot={action.leftSlot}
|
|
||||||
rightSlot={action.rightSlot}
|
|
||||||
onClick={action.onClick}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</HStack>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
return slot == null ? footer : createPortal(footer, slot);
|
|
||||||
}
|
|
||||||
@@ -1,242 +1,2 @@
|
|||||||
import { HStack, useStateWithDeps, Label } from "@yaakapp-internal/ui";
|
export type { PlainInputProps } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
export { PlainInput } from "@yaakapp-internal/ui";
|
||||||
import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react";
|
|
||||||
import {
|
|
||||||
forwardRef,
|
|
||||||
useCallback,
|
|
||||||
useImperativeHandle,
|
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useRandomKey } from "../../hooks/useRandomKey";
|
|
||||||
import { IconButton } from "./IconButton";
|
|
||||||
import type { InputProps } from "./Input";
|
|
||||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
|
||||||
|
|
||||||
export type PlainInputProps = Omit<
|
|
||||||
InputProps,
|
|
||||||
| "wrapLines"
|
|
||||||
| "onKeyDown"
|
|
||||||
| "type"
|
|
||||||
| "stateKey"
|
|
||||||
| "autocompleteVariables"
|
|
||||||
| "autocompleteFunctions"
|
|
||||||
| "autocomplete"
|
|
||||||
| "extraExtensions"
|
|
||||||
| "forcedEnvironmentId"
|
|
||||||
> &
|
|
||||||
Pick<InputHTMLAttributes<HTMLInputElement>, "inputMode" | "onKeyDownCapture" | "step"> & {
|
|
||||||
onFocusRaw?: InputHTMLAttributes<HTMLInputElement>["onFocus"];
|
|
||||||
type?: "text" | "password" | "number";
|
|
||||||
hideObscureToggle?: boolean;
|
|
||||||
labelRightSlot?: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(function PlainInput(
|
|
||||||
{
|
|
||||||
autoFocus,
|
|
||||||
autoSelect,
|
|
||||||
className,
|
|
||||||
containerClassName,
|
|
||||||
defaultValue,
|
|
||||||
disabled,
|
|
||||||
forceUpdateKey: forceUpdateKeyFromAbove,
|
|
||||||
help,
|
|
||||||
hideLabel,
|
|
||||||
hideObscureToggle,
|
|
||||||
label,
|
|
||||||
labelClassName,
|
|
||||||
labelPosition = "top",
|
|
||||||
labelRightSlot,
|
|
||||||
inputMode,
|
|
||||||
leftSlot,
|
|
||||||
name,
|
|
||||||
onBlur,
|
|
||||||
onChange,
|
|
||||||
onFocus,
|
|
||||||
onFocusRaw,
|
|
||||||
onKeyDownCapture,
|
|
||||||
onPaste,
|
|
||||||
placeholder,
|
|
||||||
required,
|
|
||||||
rightSlot,
|
|
||||||
size = "md",
|
|
||||||
step,
|
|
||||||
tint,
|
|
||||||
type = "text",
|
|
||||||
validate,
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
// Track a local key for updates. If the default value is changed when the input is not in focus,
|
|
||||||
// regenerate this to force the field to update.
|
|
||||||
const [focusedUpdateKey, regenerateFocusedUpdateKey] = useRandomKey();
|
|
||||||
const forceUpdateKey = `${forceUpdateKeyFromAbove}::${focusedUpdateKey}`;
|
|
||||||
|
|
||||||
const [obscured, setObscured] = useStateWithDeps(type === "password", [type]);
|
|
||||||
const [focused, setFocused] = useState(false);
|
|
||||||
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useImperativeHandle<{ focus: () => void } | null, { focus: () => void } | null>(
|
|
||||||
ref,
|
|
||||||
() => inputRef.current,
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFocus = useCallback(
|
|
||||||
(e: FocusEvent<HTMLInputElement>) => {
|
|
||||||
onFocusRaw?.(e);
|
|
||||||
setFocused(true);
|
|
||||||
if (autoSelect) {
|
|
||||||
inputRef.current?.select();
|
|
||||||
textareaRef.current?.select();
|
|
||||||
}
|
|
||||||
onFocus?.();
|
|
||||||
},
|
|
||||||
[autoSelect, onFocus, onFocusRaw],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleBlur = useCallback(() => {
|
|
||||||
setFocused(false);
|
|
||||||
onBlur?.();
|
|
||||||
}, [onBlur]);
|
|
||||||
|
|
||||||
// Force input to update when receiving change and not in focus
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const isFocused = document.activeElement === inputRef.current;
|
|
||||||
if (defaultValue != null && !isFocused) {
|
|
||||||
regenerateFocusedUpdateKey();
|
|
||||||
}
|
|
||||||
}, [regenerateFocusedUpdateKey, defaultValue]);
|
|
||||||
|
|
||||||
const id = useRef(`input-${generateId()}`);
|
|
||||||
const commonClassName = classNames(
|
|
||||||
className,
|
|
||||||
"bg-transparent! min-w-0 w-full focus:outline-hidden placeholder:text-placeholder",
|
|
||||||
"px-2 text-xs font-mono cursor-text",
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
onChange?.(value);
|
|
||||||
setHasChanged(true);
|
|
||||||
const isValid = (value: string) => {
|
|
||||||
if (required && !validateRequire(value)) return false;
|
|
||||||
if (typeof validate === "boolean") return validate;
|
|
||||||
if (typeof validate === "function" && !validate(value)) return false;
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
inputRef.current?.setCustomValidity(isValid(value) ? "" : "Invalid value");
|
|
||||||
},
|
|
||||||
[onChange, required, setHasChanged, validate],
|
|
||||||
);
|
|
||||||
|
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={wrapperRef}
|
|
||||||
className={classNames(
|
|
||||||
"w-full",
|
|
||||||
"pointer-events-auto", // Just in case we're placing in disabled parent
|
|
||||||
labelPosition === "left" && "flex items-center gap-2",
|
|
||||||
labelPosition === "top" && "flex-row gap-0.5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Label
|
|
||||||
htmlFor={id.current}
|
|
||||||
className={labelClassName}
|
|
||||||
visuallyHidden={hideLabel}
|
|
||||||
required={required}
|
|
||||||
help={help}
|
|
||||||
rightSlot={labelRightSlot}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Label>
|
|
||||||
<HStack
|
|
||||||
alignItems="stretch"
|
|
||||||
className={classNames(
|
|
||||||
containerClassName,
|
|
||||||
"x-theme-input",
|
|
||||||
"relative w-full rounded-md text",
|
|
||||||
"border",
|
|
||||||
"overflow-hidden",
|
|
||||||
focused && !disabled ? "border-border-focus" : "border-border-subtle",
|
|
||||||
disabled && "border-dotted",
|
|
||||||
hasChanged && "has-invalid:border-danger", // For built-in HTML validation
|
|
||||||
size === "md" && "min-h-md",
|
|
||||||
size === "sm" && "min-h-sm",
|
|
||||||
size === "xs" && "min-h-xs",
|
|
||||||
size === "2xs" && "min-h-2xs",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tint != null && (
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className={classNames(
|
|
||||||
"absolute inset-0 opacity-5 pointer-events-none",
|
|
||||||
tint === "info" && "bg-info",
|
|
||||||
tint === "warning" && "bg-warning",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{leftSlot}
|
|
||||||
<HStack
|
|
||||||
className={classNames(
|
|
||||||
"w-full min-w-0",
|
|
||||||
leftSlot ? "pl-0.5 -ml-2" : null,
|
|
||||||
rightSlot ? "pr-0.5 -mr-2" : null,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id={id.current}
|
|
||||||
ref={inputRef}
|
|
||||||
key={forceUpdateKey}
|
|
||||||
type={type === "password" && !obscured ? "text" : type}
|
|
||||||
name={name}
|
|
||||||
// oxlint-disable-next-line jsx-a11y/no-autofocus
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
defaultValue={defaultValue ?? undefined}
|
|
||||||
disabled={disabled}
|
|
||||||
autoComplete="off"
|
|
||||||
autoCapitalize="off"
|
|
||||||
autoCorrect="off"
|
|
||||||
writingsuggestions="false"
|
|
||||||
inputMode={inputMode}
|
|
||||||
onChange={(e) => handleChange(e.target.value)}
|
|
||||||
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
|
|
||||||
className={classNames(commonClassName, "h-full disabled:opacity-disabled")}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
required={required}
|
|
||||||
step={step}
|
|
||||||
placeholder={placeholder}
|
|
||||||
onKeyDownCapture={onKeyDownCapture}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
{type === "password" && !hideObscureToggle && (
|
|
||||||
<IconButton
|
|
||||||
title={
|
|
||||||
obscured
|
|
||||||
? `Show ${typeof label === "string" ? label : "field"}`
|
|
||||||
: `Obscure ${typeof label === "string" ? label : "field"}`
|
|
||||||
}
|
|
||||||
size="xs"
|
|
||||||
className="mr-0.5 group/obscure h-auto! my-0.5"
|
|
||||||
iconClassName="group-hover/obscure:text"
|
|
||||||
iconSize="sm"
|
|
||||||
icon={obscured ? "eye" : "eye_closed"}
|
|
||||||
onClick={() => setObscured((o) => !o)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{rightSlot}
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function validateRequire(v: string) {
|
|
||||||
return v.length > 0;
|
|
||||||
}
|
|
||||||
@@ -1,148 +1,2 @@
|
|||||||
import type { ShowToastRequest } from "@yaakapp-internal/plugins";
|
export type { ToastProps } from "@yaakapp-internal/ui";
|
||||||
import { Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
|
export { Toast } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
|
||||||
import * as m from "motion/react-m";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { useKey } from "react-use";
|
|
||||||
import { IconButton } from "./IconButton";
|
|
||||||
|
|
||||||
export interface ToastProps {
|
|
||||||
children: ReactNode;
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
className?: string;
|
|
||||||
timeout: number | null;
|
|
||||||
action?: (args: { hide: () => void }) => ReactNode;
|
|
||||||
icon?: ShowToastRequest["icon"] | null;
|
|
||||||
color?: ShowToastRequest["color"];
|
|
||||||
// Grow with the content (up to the viewport) instead of scrolling internally
|
|
||||||
// past the default max height
|
|
||||||
dynamicHeight?: boolean;
|
|
||||||
// Hide the close button, for toasts that render their own dismiss action.
|
|
||||||
// Escape still closes the toast
|
|
||||||
hideDismiss?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon"] | null> = {
|
|
||||||
custom: null,
|
|
||||||
danger: "alert_triangle",
|
|
||||||
info: "info",
|
|
||||||
notice: "alert_triangle",
|
|
||||||
primary: "info",
|
|
||||||
secondary: "info",
|
|
||||||
success: "check_circle",
|
|
||||||
warning: "alert_triangle",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Toast({
|
|
||||||
children,
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
timeout,
|
|
||||||
action,
|
|
||||||
icon,
|
|
||||||
color,
|
|
||||||
dynamicHeight,
|
|
||||||
hideDismiss,
|
|
||||||
}: ToastProps) {
|
|
||||||
const onCloseRef = useRef(onClose);
|
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const [autoHideCanceled, setAutoHideCanceled] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onCloseRef.current = onClose;
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const cancelAutoHide = useCallback(() => {
|
|
||||||
if (timeoutRef.current == null) return;
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
timeoutRef.current = null;
|
|
||||||
setAutoHideCanceled(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || timeout == null || autoHideCanceled) return;
|
|
||||||
|
|
||||||
timeoutRef.current = setTimeout(() => {
|
|
||||||
timeoutRef.current = null;
|
|
||||||
onCloseRef.current();
|
|
||||||
}, timeout);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (timeoutRef.current == null) return;
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
timeoutRef.current = null;
|
|
||||||
};
|
|
||||||
}, [autoHideCanceled, open, timeout]);
|
|
||||||
|
|
||||||
useKey(
|
|
||||||
"Escape",
|
|
||||||
() => {
|
|
||||||
if (!open) return;
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
[open],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toastIcon = icon === null ? null : (icon ?? (color && color in ICONS && ICONS[color]));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<m.div
|
|
||||||
initial={{ opacity: 0, right: "-10%" }}
|
|
||||||
animate={{ opacity: 100, right: 0 }}
|
|
||||||
exit={{ opacity: 0, right: "-100%" }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className={classNames("bg-surface m-2 rounded-lg")}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
`x-theme-toast x-theme-toast--${color}`,
|
|
||||||
"pointer-events-auto overflow-hidden",
|
|
||||||
"relative pointer-events-auto bg-surface text-text rounded-lg",
|
|
||||||
"border border-border shadow-lg w-100",
|
|
||||||
)}
|
|
||||||
onFocusCapture={cancelAutoHide}
|
|
||||||
onKeyDownCapture={cancelAutoHide}
|
|
||||||
onPointerDownCapture={cancelAutoHide}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"pl-3 py-3 flex items-start gap-2 w-full overflow-auto",
|
|
||||||
hideDismiss ? "pr-3" : "pr-10",
|
|
||||||
dynamicHeight ? "max-h-[80vh]" : "max-h-44",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />}
|
|
||||||
<VStack space={2} className="w-full min-w-0">
|
|
||||||
<div className="select-auto">{children}</div>
|
|
||||||
{action?.({ hide: onClose })}
|
|
||||||
</VStack>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!hideDismiss && (
|
|
||||||
<IconButton
|
|
||||||
color={color}
|
|
||||||
variant="border"
|
|
||||||
className="opacity-60 border-0 absolute! top-2 right-2"
|
|
||||||
title="Dismiss"
|
|
||||||
icon="x"
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{timeout != null && !autoHideCanceled && (
|
|
||||||
<div className="w-full absolute bottom-0 left-0 right-0">
|
|
||||||
<m.div
|
|
||||||
className="bg-surface-highlight h-[3px]"
|
|
||||||
initial={{ width: "100%" }}
|
|
||||||
animate={{ width: "0%", opacity: 0.2 }}
|
|
||||||
transition={{ duration: timeout / 1000, ease: "linear" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</m.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1 @@
|
|||||||
import { useCallback, useState } from "react";
|
export { useRandomKey } from "@yaakapp-internal/ui";
|
||||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
|
||||||
|
|
||||||
export function useRandomKey(initialValue?: string) {
|
|
||||||
const [value, setValue] = useState<string>(initialValue ?? generateId());
|
|
||||||
const regenerate = useCallback(() => setValue(generateId()), []);
|
|
||||||
return [value, regenerate] as const;
|
|
||||||
}
|
|
||||||
@@ -1,43 +1,9 @@
|
|||||||
import type { AnyModel } from "@yaakapp-internal/models";
|
import type { AnyModel } from "@yaakapp-internal/models";
|
||||||
import { foldersAtom } from "@yaakapp-internal/models";
|
import { foldersAtom } from "@yaakapp-internal/models";
|
||||||
|
import { resolvedModelName } from "@yaakapp-internal/ui/resolvedModelName";
|
||||||
import { jotaiStore } from "./jotai";
|
import { jotaiStore } from "./jotai";
|
||||||
import { replaceTemplateTags } from "./templateTags";
|
|
||||||
|
|
||||||
export function resolvedModelName(r: AnyModel | null): string {
|
export { resolvedModelName };
|
||||||
if (r == null) return "";
|
|
||||||
|
|
||||||
if (!("url" in r) || r.model === "plugin") {
|
|
||||||
return "name" in r ? r.name : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return name if it has one
|
|
||||||
if ("name" in r && r.name) {
|
|
||||||
return r.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace variable syntax with variable name
|
|
||||||
const withoutVariables = replaceTemplateTags(r.url, (m) => m.inner.trim());
|
|
||||||
if (withoutVariables.trim() === "") {
|
|
||||||
return r.model === "http_request"
|
|
||||||
? r.bodyType && r.bodyType === "graphql"
|
|
||||||
? "GraphQL Request"
|
|
||||||
: "HTTP Request"
|
|
||||||
: r.model === "websocket_request"
|
|
||||||
? "WebSocket Request"
|
|
||||||
: "gRPC Request";
|
|
||||||
}
|
|
||||||
|
|
||||||
// GRPC gets nice short names
|
|
||||||
if (r.model === "grpc_request" && r.service != null && r.method != null) {
|
|
||||||
const shortService = r.service.split(".").pop();
|
|
||||||
return `${shortService}/${r.method}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip unnecessary protocol
|
|
||||||
const withoutProto = withoutVariables.replace(/^(http|https|ws|wss):\/\//, "");
|
|
||||||
|
|
||||||
return withoutProto;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolvedModelNameWithFolders(model: AnyModel | null): string {
|
export function resolvedModelNameWithFolders(model: AnyModel | null): string {
|
||||||
return resolvedModelNameWithFoldersArray(model).join(" / ");
|
return resolvedModelNameWithFoldersArray(model).join(" / ");
|
||||||
|
|||||||
@@ -1,137 +1,2 @@
|
|||||||
/*
|
export type { TemplateTagMatch } from "@yaakapp-internal/ui/templateTags";
|
||||||
* Finding template tags in text.
|
export { findTemplateTags, replaceTemplateTags } from "@yaakapp-internal/ui/templateTags";
|
||||||
*
|
|
||||||
* A tag closes at `]}`, but a quoted argument is allowed to contain `]}` of its own — the
|
|
||||||
* printer in `crates/yaak-templates` emits `${[ fn(a='x]}y') ]}` verbatim — so the scan has to
|
|
||||||
* step over quoted strings. It mirrors the Lezer grammar in
|
|
||||||
* `components/core/Editor/twig/twig.grammar`: a terminated `'…'` (where `\` escapes whatever
|
|
||||||
* follows it) is consumed whole, and an unterminated quote is just another character, so a
|
|
||||||
* half-typed tag still closes at the next `]}` instead of swallowing the rest of the document.
|
|
||||||
*
|
|
||||||
* This was a regex until CodeQL flagged it (js/polynomial-redos): matching an unterminated
|
|
||||||
* quote needed a negative lookahead that rescanned to the end of the input at every unpaired
|
|
||||||
* quote, which is quadratic on text an attacker picks. The scan below is linear instead — see
|
|
||||||
* the notes on `closingQuoteTable` and on the dead-end set in `findTemplateTags` for why.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const QUOTE = 0x27; // '
|
|
||||||
const BACKSLASH = 0x5c; // \
|
|
||||||
const CLOSE_BRACKET = 0x5d; // ]
|
|
||||||
const CLOSE_BRACE = 0x7d; // }
|
|
||||||
|
|
||||||
/** The `${[`…`]}` bounds of one tag, and what sits between them. */
|
|
||||||
export interface TemplateTagMatch {
|
|
||||||
/** Index of the `$` that opens the tag. */
|
|
||||||
start: number;
|
|
||||||
/** Index just past the closing `]}`. */
|
|
||||||
end: number;
|
|
||||||
/** Everything between `${[` and `]}`, not trimmed. */
|
|
||||||
inner: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* For every index `i`, the index of the `'` that closes a string whose body starts at `i`, or
|
|
||||||
* `-1` when nothing does.
|
|
||||||
*
|
|
||||||
* Built right to left in a single pass, which is the trick that replaces the regex lookahead:
|
|
||||||
* whether a quote opens a terminated string depends only on the text after it, so the answer
|
|
||||||
* can be computed once for every position instead of re-derived at each quote. Skipping a
|
|
||||||
* string of any length then costs one lookup.
|
|
||||||
*/
|
|
||||||
function closingQuoteTable(text: string): Int32Array {
|
|
||||||
// Two extra slots so a `\` in the last position can read past the end and find `-1`
|
|
||||||
const table = new Int32Array(text.length + 2).fill(-1);
|
|
||||||
for (let i = text.length - 1; i >= 0; i--) {
|
|
||||||
const c = text.charCodeAt(i);
|
|
||||||
// A backslash escapes whatever follows it, including a quote
|
|
||||||
const next = c === BACKSLASH ? table[i + 2] : table[i + 1];
|
|
||||||
table[i] = c === QUOTE ? i : (next ?? -1);
|
|
||||||
}
|
|
||||||
return table;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds every template tag in `text`, in order and without overlaps.
|
|
||||||
*
|
|
||||||
* A `${[` with no `]}` after it isn't a tag, and neither is a `${[` nested inside a tag body —
|
|
||||||
* both are ordinary content, same as the regex this replaced treated them.
|
|
||||||
*/
|
|
||||||
export function findTemplateTags(text: string): TemplateTagMatch[] {
|
|
||||||
const matches: TemplateTagMatch[] = [];
|
|
||||||
|
|
||||||
// Both built only once a scan actually needs them, so short values allocate nothing
|
|
||||||
let closingQuotes: Int32Array | null = null;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Body positions a previous scan already ran off the end of the text from. Where a scan ends
|
|
||||||
* up depends only on where it starts, so a scan that walks into one of these would repeat
|
|
||||||
* that same walk and fail the same way, and can stop right there.
|
|
||||||
*
|
|
||||||
* That bound is what keeps the whole pass linear: every position enters this set at most
|
|
||||||
* once, and every step of every scan either visits a position for the first time or ends
|
|
||||||
* that scan.
|
|
||||||
*/
|
|
||||||
let deadEnds: Set<number> | null = null;
|
|
||||||
|
|
||||||
let searchFrom = 0;
|
|
||||||
while (searchFrom < text.length) {
|
|
||||||
const start = text.indexOf("${[", searchFrom);
|
|
||||||
if (start < 0) break;
|
|
||||||
|
|
||||||
const visited: number[] = [];
|
|
||||||
let end = -1;
|
|
||||||
let i = start + 3;
|
|
||||||
while (i < text.length) {
|
|
||||||
if (deadEnds?.has(i)) break;
|
|
||||||
visited.push(i);
|
|
||||||
|
|
||||||
const c = text.charCodeAt(i);
|
|
||||||
if (c === QUOTE) {
|
|
||||||
closingQuotes ??= closingQuoteTable(text);
|
|
||||||
const close = closingQuotes[i + 1] ?? -1;
|
|
||||||
// An unterminated quote is an ordinary character, so only step over a closed string
|
|
||||||
i = close < 0 ? i + 1 : close + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c === CLOSE_BRACKET && text.charCodeAt(i + 1) === CLOSE_BRACE) {
|
|
||||||
end = i + 2;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end < 0) {
|
|
||||||
// Not a tag. Resume past the opener: a real tag may start inside a string this scan
|
|
||||||
// stepped over.
|
|
||||||
deadEnds ??= new Set();
|
|
||||||
for (const position of visited) deadEnds.add(position);
|
|
||||||
searchFrom = start + 3;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
matches.push({ start, end, inner: text.slice(start + 3, end - 2) });
|
|
||||||
searchFrom = end;
|
|
||||||
}
|
|
||||||
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rewrites every template tag in `text` with whatever `replace` returns for it. */
|
|
||||||
export function replaceTemplateTags(
|
|
||||||
text: string,
|
|
||||||
replace: (match: TemplateTagMatch) => string,
|
|
||||||
): string {
|
|
||||||
const matches = findTemplateTags(text);
|
|
||||||
if (matches.length === 0) return text;
|
|
||||||
|
|
||||||
let result = "";
|
|
||||||
let copiedTo = 0;
|
|
||||||
for (const match of matches) {
|
|
||||||
result += text.slice(copiedTo, match.start) + replace(match);
|
|
||||||
copiedTo = match.end;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result + text.slice(copiedTo);
|
|
||||||
}
|
|
||||||
@@ -1,50 +1,25 @@
|
|||||||
import { atom } from "jotai";
|
// The toast store alone, without the components, which tests in a plain runtime can't load
|
||||||
import type { ToastInstance } from "../components/Toasts";
|
import {
|
||||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
hideToast as hideToastIn,
|
||||||
|
hideToastById as hideToastByIdIn,
|
||||||
|
type ShowToastArgs,
|
||||||
|
showToast as showToastIn,
|
||||||
|
type ToastInstance,
|
||||||
|
} from "@yaakapp-internal/ui/toast";
|
||||||
import { jotaiStore } from "./jotai";
|
import { jotaiStore } from "./jotai";
|
||||||
|
|
||||||
export const toastsAtom = atom<ToastInstance[]>([]);
|
export { toastsAtom } from "@yaakapp-internal/ui/toast";
|
||||||
|
|
||||||
export function showToast({
|
export function showToast(args: ShowToastArgs) {
|
||||||
id,
|
return showToastIn(jotaiStore, args);
|
||||||
timeout = 5000,
|
|
||||||
...props
|
|
||||||
}: Omit<ToastInstance, "id" | "timeout" | "uniqueKey"> & {
|
|
||||||
id?: ToastInstance["id"];
|
|
||||||
timeout?: ToastInstance["timeout"];
|
|
||||||
}) {
|
|
||||||
id = id ?? generateId();
|
|
||||||
const uniqueKey = generateId();
|
|
||||||
|
|
||||||
const toasts = jotaiStore.get(toastsAtom);
|
|
||||||
const toastWithSameId = toasts.find((t) => t.id === id);
|
|
||||||
|
|
||||||
let delay = 0;
|
|
||||||
if (toastWithSameId) {
|
|
||||||
hideToast(toastWithSameId);
|
|
||||||
// Allow enough time for old toast to animate out
|
|
||||||
delay = 200;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const newToast: ToastInstance = { id, uniqueKey, timeout, ...props };
|
|
||||||
jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]);
|
|
||||||
}, delay);
|
|
||||||
|
|
||||||
return id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hideToastById(id: string) {
|
export function hideToastById(id: string) {
|
||||||
const toast = jotaiStore.get(toastsAtom).find((t) => t.id === id);
|
hideToastByIdIn(jotaiStore, id);
|
||||||
if (toast) hideToast(toast);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hideToast(toHide: ToastInstance) {
|
export function hideToast(toHide: ToastInstance) {
|
||||||
jotaiStore.set(toastsAtom, (all) => {
|
hideToastIn(jotaiStore, toHide);
|
||||||
const t = all.find((t) => t.uniqueKey === toHide.uniqueKey);
|
|
||||||
t?.onClose?.();
|
|
||||||
return all.filter((t) => t.uniqueKey !== toHide.uniqueKey);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showErrorToast<T>({
|
export function showErrorToast<T>({
|
||||||
|
|||||||
@@ -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,
|
HStack,
|
||||||
HttpStatusTagRaw,
|
HttpStatusTagRaw,
|
||||||
Icon,
|
Icon,
|
||||||
|
IconButton,
|
||||||
SizeTag,
|
SizeTag,
|
||||||
type TabButtonRenderProps,
|
type TabButtonRenderProps,
|
||||||
type TabItem,
|
type TabItem,
|
||||||
@@ -17,11 +18,12 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
} from "@yaakapp-internal/ui";
|
} from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { selectedFlowAtom } from "../lib/atoms";
|
import { selectedFlowAtom } from "../lib/atoms";
|
||||||
import { findHeader, mimeEssence } from "../lib/body";
|
import { findHeader, mimeEssence } from "../lib/body";
|
||||||
|
import { saveDialogFlowAtom, useFlowActions } from "../lib/flowActions";
|
||||||
import { statusReason } from "../lib/statusReason";
|
import { statusReason } from "../lib/statusReason";
|
||||||
import { EmptyState, FlowBodyViewer, useFlowBody } from "./FlowBodyViewer";
|
import { EmptyState, FlowBodyViewer, useFlowBody } from "./FlowBodyViewer";
|
||||||
import { FlowHeaders, FlowQuery, queryParams } from "./FlowHeaders";
|
import { FlowHeaders, FlowQuery, queryParams } from "./FlowHeaders";
|
||||||
@@ -50,10 +52,8 @@ export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideB
|
|||||||
<div className="grid grid-cols-[auto_auto] gap-x-6 gap-y-2 text-left">
|
<div className="grid grid-cols-[auto_auto] gap-x-6 gap-y-2 text-left">
|
||||||
<span>Select a flow</span>
|
<span>Select a flow</span>
|
||||||
<Keys keys={["↑", "↓"]} />
|
<Keys keys={["↑", "↓"]} />
|
||||||
<span>Start the proxy</span>
|
<span>Pause or resume recording</span>
|
||||||
<Keys keys={["⌘", "⇧", "P"]} />
|
<Keys keys={["⌘", "R"]} />
|
||||||
<span>Stop the proxy</span>
|
|
||||||
<Keys keys={["⌘", "⇧", "S"]} />
|
|
||||||
<span>Clear flows</span>
|
<span>Clear flows</span>
|
||||||
<Keys keys={["⌘", "K"]} />
|
<Keys keys={["⌘", "K"]} />
|
||||||
</div>
|
</div>
|
||||||
@@ -73,6 +73,8 @@ function FlowPane({ flow }: { flow: Flow }) {
|
|||||||
const noResponse = flow.error != null && flow.status == null;
|
const noResponse = flow.error != null && flow.status == null;
|
||||||
const mime = mimeEssence(findHeader(flow.responseHeaders, "content-type"));
|
const mime = mimeEssence(findHeader(flow.responseHeaders, "content-type"));
|
||||||
const queryCount = useMemo(() => queryParams(flow.url).length, [flow.url]);
|
const queryCount = useMemo(() => queryParams(flow.url).length, [flow.url]);
|
||||||
|
const openSave = useSetAtom(saveDialogFlowAtom);
|
||||||
|
const flowActions = useFlowActions();
|
||||||
|
|
||||||
const tabs = useMemo<TabItem[]>(
|
const tabs = useMemo<TabItem[]>(
|
||||||
() => [
|
() => [
|
||||||
@@ -125,7 +127,7 @@ function FlowPane({ flow }: { flow: Flow }) {
|
|||||||
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
|
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"grid grid-cols-[auto_minmax(4rem,1fr)] items-center h-sm -mb-1.5",
|
"grid grid-cols-[auto_minmax(4rem,1fr)_auto] items-center h-sm -mb-1.5",
|
||||||
"cursor-default select-none whitespace-nowrap w-full px-3",
|
"cursor-default select-none whitespace-nowrap w-full px-3",
|
||||||
"overflow-x-auto font-mono text-sm text-text-subtle hide-scrollbars",
|
"overflow-x-auto font-mono text-sm text-text-subtle hide-scrollbars",
|
||||||
)}
|
)}
|
||||||
@@ -172,6 +174,21 @@ function FlowPane({ flow }: { flow: Flow }) {
|
|||||||
) : (
|
) : (
|
||||||
<span />
|
<span />
|
||||||
)}
|
)}
|
||||||
|
<HStack space={1} className="font-sans ml-3">
|
||||||
|
<Button
|
||||||
|
size="2xs"
|
||||||
|
variant="border"
|
||||||
|
color={flow.savedRequestId == null ? "default" : "primary"}
|
||||||
|
title={flow.savedRequestId == null ? undefined : "Saved to Yaak. Click to save again."}
|
||||||
|
leftSlot={<Icon icon="bookmark" size="xs" />}
|
||||||
|
onClick={() => openSave(flow)}
|
||||||
|
>
|
||||||
|
{flow.savedRequestId == null ? "Save to Yaak" : "Saved"}
|
||||||
|
</Button>
|
||||||
|
<Dropdown items={flowActions(flow).slice(2)}>
|
||||||
|
<IconButton size="xs" icon="ellipsis" iconColor="secondary" title="Copy and export" />
|
||||||
|
</Dropdown>
|
||||||
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden flex flex-col min-h-0">
|
<div className="overflow-hidden flex flex-col min-h-0">
|
||||||
|
|||||||
@@ -1,17 +1,34 @@
|
|||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||||
import { formatMillis, HttpMethodTagRaw, HttpStatusTagRaw, Icon } from "@yaakapp-internal/ui";
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
formatMillis,
|
||||||
|
HttpMethodTagRaw,
|
||||||
|
HttpStatusTagRaw,
|
||||||
|
Icon,
|
||||||
|
} from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import type { CSSProperties, KeyboardEvent, WheelEvent } from "react";
|
import type { CSSProperties, KeyboardEvent, MouseEvent, WheelEvent } from "react";
|
||||||
import { memo, useCallback, useLayoutEffect, useRef, useState } from "react";
|
import { memo, useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { selectedFlowAtom, selectedFlowIdAtom } from "../lib/atoms";
|
import { selectedFlowAtom, selectedFlowIdAtom } from "../lib/atoms";
|
||||||
import { shortContentType } from "../lib/body";
|
import { shortContentType } from "../lib/body";
|
||||||
|
import { useFlowActions } from "../lib/flowActions";
|
||||||
import { compareFlows } from "../lib/flowWindow";
|
import { compareFlows } from "../lib/flowWindow";
|
||||||
import { countNewFlows } from "../lib/follow";
|
import { countNewFlows } from "../lib/follow";
|
||||||
import { followAtom, loadOlderFlowsAtom, newestStartedAtAtom, oldestFlowAtom } from "../lib/store";
|
import {
|
||||||
import { filteredExchangesAtom } from "./Sidebar";
|
activeQueryAtom,
|
||||||
|
followAtom,
|
||||||
|
hasAppsAtom,
|
||||||
|
loadOlderFlowsAtom,
|
||||||
|
newestStartedAtAtom,
|
||||||
|
oldestFlowAtom,
|
||||||
|
searchWindowAtom,
|
||||||
|
shownFlowsAtom,
|
||||||
|
} from "../lib/store";
|
||||||
|
import { AppIcon } from "./AppIcon";
|
||||||
|
import { SearchBar } from "./SearchBar";
|
||||||
|
|
||||||
const ROW_HEIGHT = 24;
|
const ROW_HEIGHT = 24;
|
||||||
const BOTTOM_PADDING = 6;
|
const BOTTOM_PADDING = 6;
|
||||||
@@ -29,7 +46,10 @@ interface Props {
|
|||||||
* the top loads older flows from the database.
|
* the top loads older flows from the database.
|
||||||
*/
|
*/
|
||||||
export function FlowList({ sideBySide, style, className }: Props) {
|
export function FlowList({ sideBySide, style, className }: Props) {
|
||||||
const flows = useAtomValue(filteredExchangesAtom);
|
const flows = useAtomValue(shownFlowsAtom);
|
||||||
|
const hasApps = useAtomValue(hasAppsAtom);
|
||||||
|
const [menu, setMenu] = useState<{ flow: Flow; x: number; y: number } | null>(null);
|
||||||
|
const flowActions = useFlowActions();
|
||||||
const selectedId = useAtomValue(selectedFlowIdAtom);
|
const selectedId = useAtomValue(selectedFlowIdAtom);
|
||||||
const setSelectedFlow = useSetAtom(selectedFlowAtom);
|
const setSelectedFlow = useSetAtom(selectedFlowAtom);
|
||||||
const loadOlder = useSetAtom(loadOlderFlowsAtom);
|
const loadOlder = useSetAtom(loadOlderFlowsAtom);
|
||||||
@@ -153,17 +173,33 @@ export function FlowList({ sideBySide, style, className }: Props) {
|
|||||||
[flows, loadOlderHeld, select, selectedId],
|
[flows, loadOlderHeld, select, selectedId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openMenu = useCallback(
|
||||||
|
(index: number, e: MouseEvent) => {
|
||||||
|
const flow = latest.current.flows[index];
|
||||||
|
if (flow == null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
select(index);
|
||||||
|
setMenu({ flow, x: e.clientX, y: e.clientY });
|
||||||
|
},
|
||||||
|
[select],
|
||||||
|
);
|
||||||
|
|
||||||
const newCount = following ? 0 : countNewFlows(flows, heldAt);
|
const newCount = following ? 0 : countNewFlows(flows, heldAt);
|
||||||
|
|
||||||
const columns = sideBySide
|
const columns = sideBySide
|
||||||
? "grid-cols-[0.9rem_2.6rem_minmax(0,2fr)_minmax(0,3fr)_2.4rem_3.6rem]"
|
? hasApps
|
||||||
: "grid-cols-[0.9rem_2.6rem_13rem_minmax(0,1fr)_2.4rem_5rem_4.5rem_3.8rem]";
|
? "grid-cols-[0.9rem_1rem_2.6rem_minmax(0,2fr)_minmax(0,3fr)_2.4rem_3.6rem]"
|
||||||
|
: "grid-cols-[0.9rem_2.6rem_minmax(0,2fr)_minmax(0,3fr)_2.4rem_3.6rem]"
|
||||||
|
: hasApps
|
||||||
|
? "grid-cols-[0.9rem_8.5rem_2.6rem_13rem_minmax(0,1fr)_2.4rem_5rem_4.5rem_3.8rem]"
|
||||||
|
: "grid-cols-[0.9rem_2.6rem_13rem_minmax(0,1fr)_2.4rem_5rem_4.5rem_3.8rem]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={style}
|
style={style}
|
||||||
className={classNames(className, "grid grid-rows-[auto_minmax(0,1fr)] min-h-0 text-sm")}
|
className={classNames(className, "grid grid-rows-[auto_auto_minmax(0,1fr)] min-h-0 text-sm")}
|
||||||
>
|
>
|
||||||
|
<SearchBar />
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
columns,
|
columns,
|
||||||
@@ -172,6 +208,7 @@ export function FlowList({ sideBySide, style, className }: Props) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span />
|
<span />
|
||||||
|
{hasApps && <span>{sideBySide ? "" : "App"}</span>}
|
||||||
<span>Method</span>
|
<span>Method</span>
|
||||||
<span>Host</span>
|
<span>Host</span>
|
||||||
<span>Path</span>
|
<span>Path</span>
|
||||||
@@ -192,7 +229,7 @@ export function FlowList({ sideBySide, style, className }: Props) {
|
|||||||
className="group/flows h-full overflow-y-auto px-1.5 outline-hidden"
|
className="group/flows h-full overflow-y-auto px-1.5 outline-hidden"
|
||||||
>
|
>
|
||||||
{flows.length === 0 ? (
|
{flows.length === 0 ? (
|
||||||
<p className="text-text-subtlest text-sm px-3 py-3">No traffic yet</p>
|
<EmptyList />
|
||||||
) : (
|
) : (
|
||||||
<div className="relative" style={{ height: virtualizer.getTotalSize() }}>
|
<div className="relative" style={{ height: virtualizer.getTotalSize() }}>
|
||||||
{virtualizer.getVirtualItems().map((item) => {
|
{virtualizer.getVirtualItems().map((item) => {
|
||||||
@@ -206,8 +243,10 @@ export function FlowList({ sideBySide, style, className }: Props) {
|
|||||||
start={item.start}
|
start={item.start}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
sideBySide={sideBySide}
|
sideBySide={sideBySide}
|
||||||
|
showApp={hasApps}
|
||||||
selected={flow.id === selectedId}
|
selected={flow.id === selectedId}
|
||||||
onSelect={select}
|
onSelect={select}
|
||||||
|
onContextMenu={openMenu}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -229,19 +268,38 @@ export function FlowList({ sideBySide, style, className }: Props) {
|
|||||||
{newCount === 1 ? "1 new flow" : `${newCount} new flows`}
|
{newCount === 1 ? "1 new flow" : `${newCount} new flows`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<ContextMenu
|
||||||
|
triggerPosition={menu}
|
||||||
|
items={menu == null ? [] : flowActions(menu.flow)}
|
||||||
|
onClose={() => setMenu(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nothing to list, or nothing a search matches */
|
||||||
|
function EmptyList() {
|
||||||
|
const query = useAtomValue(activeQueryAtom);
|
||||||
|
const search = useAtomValue(searchWindowAtom);
|
||||||
|
if (search?.loading) return null;
|
||||||
|
return (
|
||||||
|
<p className="text-text-subtlest text-sm px-3 py-3">
|
||||||
|
{query == null ? "No traffic yet" : "No flows match"}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface RowProps {
|
interface RowProps {
|
||||||
flow: Flow;
|
flow: Flow;
|
||||||
index: number;
|
index: number;
|
||||||
start: number;
|
start: number;
|
||||||
columns: string;
|
columns: string;
|
||||||
sideBySide: boolean;
|
sideBySide: boolean;
|
||||||
|
showApp: boolean;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onSelect: (index: number) => void;
|
onSelect: (index: number) => void;
|
||||||
|
onContextMenu: (index: number, e: MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FlowRow = memo(function FlowRow({
|
const FlowRow = memo(function FlowRow({
|
||||||
@@ -250,8 +308,10 @@ const FlowRow = memo(function FlowRow({
|
|||||||
start,
|
start,
|
||||||
columns,
|
columns,
|
||||||
sideBySide,
|
sideBySide,
|
||||||
|
showApp,
|
||||||
selected,
|
selected,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onContextMenu,
|
||||||
}: RowProps) {
|
}: RowProps) {
|
||||||
const { host, path, query } = splitUrl(flow.url);
|
const { host, path, query } = splitUrl(flow.url);
|
||||||
const failed = flow.error != null;
|
const failed = flow.error != null;
|
||||||
@@ -263,7 +323,10 @@ const FlowRow = memo(function FlowRow({
|
|||||||
<div
|
<div
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={selected}
|
aria-selected={selected}
|
||||||
onMouseDown={() => onSelect(index)}
|
onMouseDown={(e) => {
|
||||||
|
if (e.button === 0) onSelect(index);
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => onContextMenu(index, e)}
|
||||||
style={{ height: ROW_HEIGHT, transform: `translateY(${start}px)` }}
|
style={{ height: ROW_HEIGHT, transform: `translateY(${start}px)` }}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
columns,
|
columns,
|
||||||
@@ -275,12 +338,24 @@ const FlowRow = memo(function FlowRow({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center justify-center">
|
<span className="flex items-center justify-center">
|
||||||
{failed ? (
|
{flow.savedRequestId != null ? (
|
||||||
|
<Icon icon="bookmark" size="xs" color="primary" title="Saved to Yaak" />
|
||||||
|
) : failed ? (
|
||||||
<Icon icon="circle_alert" size="xs" color="danger" />
|
<Icon icon="circle_alert" size="xs" color="danger" />
|
||||||
) : flow.responseTruncated ? (
|
) : flow.responseTruncated ? (
|
||||||
<Icon icon="scissors" size="xs" color="notice" title="Body cut at the capture limit" />
|
<Icon icon="scissors" size="xs" color="notice" title="Body cut at the capture limit" />
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
{showApp && (
|
||||||
|
<span className="flex items-center gap-1.5" title={flow.clientPath ?? undefined}>
|
||||||
|
{flow.clientPath != null && <AppIcon path={flow.clientPath} />}
|
||||||
|
{!sideBySide && (
|
||||||
|
<span className="truncate text-text-subtle">
|
||||||
|
{flow.clientName ?? <span className="text-text-subtlest">--</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<HttpMethodTagRaw method={flow.method} short colored />
|
<HttpMethodTagRaw method={flow.method} short colored />
|
||||||
<span className="text-text-subtle">{host}</span>
|
<span className="text-text-subtle">{host}</span>
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
import { HeaderSize, IconButton, SidebarLayout, SplitLayout } from "@yaakapp-internal/ui";
|
import {
|
||||||
|
HeaderSize,
|
||||||
|
IconButton,
|
||||||
|
SidebarLayout,
|
||||||
|
SplitLayout,
|
||||||
|
type SplitLayoutLayout,
|
||||||
|
Toasts,
|
||||||
|
} from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useLocalStorage } from "react-use";
|
import { useLocalStorage } from "react-use";
|
||||||
import { flowLayoutAtom } from "../lib/atoms";
|
import { flowLayoutAtom } from "../lib/atoms";
|
||||||
|
import { searchWindowAtom, showFirstRunAtom, shownTotalAtom } from "../lib/store";
|
||||||
import { getOsType } from "../lib/tauri";
|
import { getOsType } from "../lib/tauri";
|
||||||
import { ClearFlowsButton } from "./ClearFlowsButton";
|
import { ClearFlowsButton } from "./ClearFlowsButton";
|
||||||
|
import { FirstRun } from "./FirstRun";
|
||||||
import { FlowDetail } from "./FlowDetail";
|
import { FlowDetail } from "./FlowDetail";
|
||||||
import { FlowList } from "./FlowList";
|
import { FlowList } from "./FlowList";
|
||||||
import { ProxyStatus } from "./ProxyStatus";
|
import { ProxyStatus } from "./ProxyStatus";
|
||||||
import { filteredCountAtom, selectedSidebarLabelAtom, Sidebar } from "./Sidebar";
|
import { SaveDialog } from "./SaveDialog";
|
||||||
|
import { selectedSidebarLabelAtom, Sidebar } from "./Sidebar";
|
||||||
|
import { TakeOverBanner } from "./TakeOverBanner";
|
||||||
|
|
||||||
export function ProxyLayout() {
|
export function ProxyLayout() {
|
||||||
const os = getOsType();
|
const os = getOsType();
|
||||||
@@ -120,27 +131,45 @@ export function ProxyLayout() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SplitLayout
|
<div className="h-full flex flex-col min-h-0">
|
||||||
storageKey="proxy_detail"
|
<TakeOverBanner />
|
||||||
className="gap-1"
|
<div className="flex-1 min-h-0 grid grid-rows-[minmax(0,1fr)]">
|
||||||
layout={flowLayout}
|
<FlowsOrFirstRun flowLayout={flowLayout} />
|
||||||
defaultRatio={0.55}
|
</div>
|
||||||
firstSlot={({ style, orientation }) => (
|
</div>
|
||||||
<FlowList sideBySide={orientation === "horizontal"} style={style} className="pt-1" />
|
|
||||||
)}
|
|
||||||
secondSlot={({ style, orientation }) => (
|
|
||||||
<FlowDetail style={style} sideBySide={orientation === "horizontal"} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SidebarLayout>
|
</SidebarLayout>
|
||||||
|
<SaveDialog />
|
||||||
|
<Toasts />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Its own component, so new traffic re-renders the count rather than the whole layout */
|
/** Its own component, so new traffic re-renders the count rather than the whole layout */
|
||||||
function FlowCount() {
|
function FlowCount() {
|
||||||
const count = useAtomValue(filteredCountAtom);
|
const count = useAtomValue(shownTotalAtom);
|
||||||
|
const searching = useAtomValue(searchWindowAtom) != null;
|
||||||
|
const noun = searching ? (count === 1 ? "match" : "matches") : count === 1 ? "flow" : "flows";
|
||||||
|
return <span className="text-text-subtlest shrink-0">{`${count} ${noun}`}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The flow list above the detail pane, or, while the database has no flows at all, the first-run
|
||||||
|
* card across the whole area: it is the empty state until traffic replaces it.
|
||||||
|
*/
|
||||||
|
function FlowsOrFirstRun({ flowLayout }: { flowLayout: SplitLayoutLayout }) {
|
||||||
|
if (useAtomValue(showFirstRunAtom)) return <FirstRun />;
|
||||||
return (
|
return (
|
||||||
<span className="text-text-subtlest shrink-0">{count === 1 ? "1 flow" : `${count} flows`}</span>
|
<SplitLayout
|
||||||
|
storageKey="proxy_detail"
|
||||||
|
className="gap-1"
|
||||||
|
layout={flowLayout}
|
||||||
|
defaultRatio={0.55}
|
||||||
|
firstSlot={({ style, orientation }) => (
|
||||||
|
<FlowList sideBySide={orientation === "horizontal"} style={style} className="pt-1" />
|
||||||
|
)}
|
||||||
|
secondSlot={({ style, orientation }) => (
|
||||||
|
<FlowDetail style={style} sideBySide={orientation === "horizontal"} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,43 +1,135 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type { CaStatus } from "@yaakapp-internal/proxy-lib";
|
import type { CaStatus, SystemProxyStatus } from "@yaakapp-internal/proxy-lib";
|
||||||
import { Button, Dropdown, type DropdownItem, type DropdownRef, Icon } from "@yaakapp-internal/ui";
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Dropdown,
|
||||||
|
type DropdownItem,
|
||||||
|
type DropdownRef,
|
||||||
|
Icon,
|
||||||
|
} from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { useAtomValue, useStore } from "jotai";
|
||||||
import type { HTMLAttributes, ReactNode, Ref } from "react";
|
import type { HTMLAttributes, ReactNode, Ref } from "react";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
import { useRpcEvent } from "../hooks/useRpcEvent";
|
||||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||||
import { useRpcQueryWithEvent } from "../hooks/useRpcQueryWithEvent";
|
import { environmentExports } from "../lib/env";
|
||||||
|
import { proxyStateAtom, refreshProxyState } from "../lib/store";
|
||||||
|
import { recordingLabel, systemProxySwitch } from "../lib/systemProxy";
|
||||||
|
import { useShowToast } from "../lib/toast";
|
||||||
|
|
||||||
/** The header's state pill and its menu, with the certificate stepping out when untrusted */
|
/**
|
||||||
|
* The header's state pill and its menu: recording, the system proxy and the certificate. The
|
||||||
|
* engine runs for as long as the app does, so the pill shows whether flows are being stored. A
|
||||||
|
* certificate that is not trusted steps out of the menu beside the pill until it is.
|
||||||
|
*/
|
||||||
export function ProxyStatus() {
|
export function ProxyStatus() {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const dropdownRef = useRef<DropdownRef>(null);
|
const dropdownRef = useRef<DropdownRef>(null);
|
||||||
const { data: proxyState } = useRpcQueryWithEvent("get_proxy_state", {}, "proxy_state_changed");
|
const proxyState = useAtomValue(proxyStateAtom);
|
||||||
const caStatus = useRpcQuery("ca_status", {});
|
const setup = useProxySetup();
|
||||||
const trust = useRpcMutation("ca_trust", {
|
|
||||||
onSuccess: (status) => queryClient.setQueryData<CaStatus>(["ca_status", {}], status),
|
|
||||||
});
|
|
||||||
const execute = useRpcMutation("execute_action");
|
const execute = useRpcMutation("execute_action");
|
||||||
|
const retry = useRpcMutation("take_over");
|
||||||
|
const setAtLaunch = useSetSystemProxyAtLaunch();
|
||||||
|
const copyEnvironment = useCopyEnvironment();
|
||||||
|
const { caStatus, trust, systemProxy, setSystemProxy } = setup;
|
||||||
|
const systemSwitch = useSystemProxySwitch(setup);
|
||||||
|
|
||||||
const running = proxyState?.state === "running";
|
const running = proxyState?.state === "running";
|
||||||
const port = proxyState?.port;
|
const recording = proxyState?.recording !== false;
|
||||||
|
const other = proxyState?.otherOwner ?? null;
|
||||||
|
const port = proxyState?.port ?? other?.proxyPort ?? null;
|
||||||
const untrusted = caStatus.data != null && !caStatus.data.trusted;
|
const untrusted = caStatus.data != null && !caStatus.data.trusted;
|
||||||
|
|
||||||
|
const stateRow =
|
||||||
|
other != null ? (
|
||||||
|
<MenuRow
|
||||||
|
icon={<StateDot state="cli" />}
|
||||||
|
title="Running from the CLI"
|
||||||
|
description={`Started by yaak proxy start, pid ${other.pid}, since ${timeOf(other.startedAt)}.${other.proxyPort == null ? "" : ` Port ${other.proxyPort}.`}`}
|
||||||
|
/>
|
||||||
|
) : running ? (
|
||||||
|
<MenuRow
|
||||||
|
icon={<StateDot state={recording ? "recording" : "paused"} />}
|
||||||
|
title={recording ? `Recording on port ${port}` : `Paused on port ${port}`}
|
||||||
|
description={
|
||||||
|
recording
|
||||||
|
? "Listening on 127.0.0.1, and storing every flow."
|
||||||
|
: "Traffic passes through untouched, and no flows are stored."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MenuRow
|
||||||
|
icon={<Icon icon="alert_triangle" size="sm" color="warning" />}
|
||||||
|
title="Not running"
|
||||||
|
description={
|
||||||
|
<span className="text-danger">
|
||||||
|
{retry.error?.message ?? proxyState?.startError ?? "The proxy has not started."}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
size="2xs"
|
||||||
|
variant="border"
|
||||||
|
isLoading={retry.isPending}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
retry.mutate({});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
const items: DropdownItem[] = [
|
const items: DropdownItem[] = [
|
||||||
|
{ type: "content", label: stateRow },
|
||||||
|
{ type: "separator", hidden: systemProxy.data?.supported !== true },
|
||||||
{
|
{
|
||||||
type: "content",
|
type: "content",
|
||||||
|
hidden: systemProxy.data?.supported !== true,
|
||||||
label: (
|
label: (
|
||||||
<MenuRow
|
<MenuRow
|
||||||
icon={<StateDot running={running} />}
|
icon={<Icon icon="wifi" size="sm" color="secondary" />}
|
||||||
title={running && port != null ? `Running on port ${port}` : "Stopped"}
|
title="Override system proxy"
|
||||||
description={
|
description={
|
||||||
running
|
<span className={classNames(systemSwitch.failed && "text-danger")}>
|
||||||
? "Listening on 127.0.0.1."
|
{systemSwitch.description}
|
||||||
: "Start the proxy to capture traffic from apps pointed at it."
|
</span>
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<KeepMenuOpen>
|
||||||
|
<Checkbox
|
||||||
|
title="Override system proxy"
|
||||||
|
hideLabel
|
||||||
|
checked={systemSwitch.checked}
|
||||||
|
// Only the process that owns the engine routes to it.
|
||||||
|
disabled={!running || other != null || setSystemProxy.isPending}
|
||||||
|
onChange={(enabled) => setSystemProxy.mutate({ enabled })}
|
||||||
|
/>
|
||||||
|
</KeepMenuOpen>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ type: "separator" },
|
{
|
||||||
|
type: "content",
|
||||||
|
hidden: systemProxy.data?.supported !== true,
|
||||||
|
label: (
|
||||||
|
<KeepMenuOpen className="block pl-[2.125rem] pr-1.5 pb-1.5">
|
||||||
|
<Checkbox
|
||||||
|
title={
|
||||||
|
<span className="text-xs text-text-subtle">Override system proxy at launch</span>
|
||||||
|
}
|
||||||
|
checked={proxyState?.systemProxyAtLaunch ?? true}
|
||||||
|
disabled={setAtLaunch.isPending}
|
||||||
|
onChange={(enabled) => setAtLaunch.mutate({ enabled })}
|
||||||
|
/>
|
||||||
|
</KeepMenuOpen>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "content",
|
type: "content",
|
||||||
label: caStatus.data?.trusted ? (
|
label: caStatus.data?.trusted ? (
|
||||||
@@ -78,13 +170,17 @@ export function ProxyStatus() {
|
|||||||
},
|
},
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
label: running ? "Stop Proxy" : "Start Proxy",
|
label: "Copy Environment for a Terminal",
|
||||||
icon: running ? "circle_stop" : "circle_play",
|
icon: "square_terminal",
|
||||||
hotKeyAction: running ? "proxy_stop" : "proxy_start",
|
onSelect: copyEnvironment,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: recordingLabel(recording),
|
||||||
|
icon: recording ? "circle_pause" : "circle_play",
|
||||||
|
hidden: !running,
|
||||||
|
hotKeyAction: "recording_toggle",
|
||||||
hotKeyLabelOnly: true,
|
hotKeyLabelOnly: true,
|
||||||
onSelect: () => {
|
onSelect: () => execute.mutate({ scope: "global", action: "recording_toggle" }),
|
||||||
execute.mutate({ scope: "global", action: running ? "proxy_stop" : "proxy_start" });
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -97,17 +193,25 @@ export function ProxyStatus() {
|
|||||||
</Pill>
|
</Pill>
|
||||||
)}
|
)}
|
||||||
<Dropdown ref={dropdownRef} items={items}>
|
<Dropdown ref={dropdownRef} items={items}>
|
||||||
<Pill>
|
<Pill color={!running && other == null && proxyState != null ? "warning" : undefined}>
|
||||||
{running ? (
|
{other != null ? (
|
||||||
<>
|
<>
|
||||||
<StateDot running />
|
<StateDot state="cli" />
|
||||||
<span className="text-text">Running</span>
|
<span className="text-text">CLI</span>
|
||||||
|
{other.proxyPort != null && (
|
||||||
|
<span className="font-mono text-text-subtle">:{other.proxyPort}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : running ? (
|
||||||
|
<>
|
||||||
|
<StateDot state={recording ? "recording" : "paused"} />
|
||||||
|
<span className="text-text">{recording ? "Recording" : "Paused"}</span>
|
||||||
{port != null && <span className="font-mono text-text-subtle">:{port}</span>}
|
{port != null && <span className="font-mono text-text-subtle">:{port}</span>}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<StateDot running={false} />
|
<Icon icon="alert_triangle" size="xs" />
|
||||||
Stopped
|
Not running
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Icon icon="chevron_down" size="xs" className="text-text-subtlest" />
|
<Icon icon="chevron_down" size="xs" className="text-text-subtlest" />
|
||||||
@@ -117,6 +221,83 @@ export function ProxyStatus() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The certificate and the system proxy, read once and shared by the header and the first-run
|
||||||
|
* card, with the actions that change them.
|
||||||
|
*/
|
||||||
|
export function useProxySetup() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const caStatus = useRpcQuery("ca_status", {});
|
||||||
|
const systemProxy = useRpcQuery("system_proxy_status", {});
|
||||||
|
const trust = useRpcMutation("ca_trust", {
|
||||||
|
mutationKey: ["ca_trust"],
|
||||||
|
onSuccess: (status) => queryClient.setQueryData<CaStatus>(["ca_status", {}], status),
|
||||||
|
});
|
||||||
|
const setSystemProxy = useRpcMutation("system_proxy_set", {
|
||||||
|
mutationKey: ["system_proxy_set"],
|
||||||
|
onSuccess: (status) => {
|
||||||
|
queryClient.setQueryData<SystemProxyStatus>(["system_proxy_status", {}], status);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// The engine turns the system proxy on as it starts, and says so once the prompt is
|
||||||
|
// answered, and turns it off as it stops.
|
||||||
|
useRpcEvent("proxy_state_changed", () => void systemProxy.refetch());
|
||||||
|
return { caStatus, trust, systemProxy, setSystemProxy };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The system proxy switch's state, from where it points and the change in flight */
|
||||||
|
export function useSystemProxySwitch(setup: ReturnType<typeof useProxySetup>) {
|
||||||
|
const proxyState = useAtomValue(proxyStateAtom);
|
||||||
|
const { systemProxy, setSystemProxy } = setup;
|
||||||
|
return systemProxySwitch({
|
||||||
|
routing: systemProxy.data?.enabled === true,
|
||||||
|
port: proxyState?.port ?? proxyState?.configuredPort ?? null,
|
||||||
|
pending: setSystemProxy.isPending ? (setSystemProxy.variables?.enabled ?? null) : null,
|
||||||
|
error: setSystemProxy.error?.message ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saves whether the system proxy turns on as the app launches */
|
||||||
|
function useSetSystemProxyAtLaunch() {
|
||||||
|
const store = useStore();
|
||||||
|
return useRpcMutation("set_system_proxy_at_launch", {
|
||||||
|
onSettled: () => void refreshProxyState(store),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCopyEnvironment() {
|
||||||
|
const proxyState = useAtomValue(proxyStateAtom);
|
||||||
|
const caStatus = useRpcQuery("ca_status", {});
|
||||||
|
const showToast = useShowToast();
|
||||||
|
return async () => {
|
||||||
|
const port =
|
||||||
|
proxyState?.port ?? proxyState?.otherOwner?.proxyPort ?? proxyState?.configuredPort;
|
||||||
|
const caPath = caStatus.data?.certPath;
|
||||||
|
if (port == null || caPath == null) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(environmentExports(port, caPath));
|
||||||
|
showToast({
|
||||||
|
message: "Copied the environment. Paste it into a terminal to proxy what runs there.",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
showToast({ message: `Failed to copy: ${String(err)}`, color: "danger" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** Keeps a click on a control inside a menu row from closing the menu, as a row's click does */
|
||||||
|
function KeepMenuOpen({ children, className }: { children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- only stops the click reaching the row
|
||||||
|
<span className={className} onClick={(e) => e.stopPropagation()}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeOf(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
interface PillProps extends HTMLAttributes<HTMLButtonElement> {
|
interface PillProps extends HTMLAttributes<HTMLButtonElement> {
|
||||||
color?: "warning";
|
color?: "warning";
|
||||||
ref?: Ref<HTMLButtonElement>;
|
ref?: Ref<HTMLButtonElement>;
|
||||||
@@ -141,13 +322,15 @@ function Pill({ children, color, ref, ...props }: PillProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StateDot({ running }: { running: boolean }) {
|
export function StateDot({ state }: { state: "recording" | "paused" | "cli" }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"inline-block w-[7px] h-[7px] rounded-full shrink-0",
|
"inline-block w-[7px] h-[7px] rounded-full shrink-0",
|
||||||
running ? "bg-success ring-3 ring-success/20" : "border-[1.5px] border-text-subtlest",
|
state === "recording" && "bg-success ring-3 ring-success/20",
|
||||||
|
state === "cli" && "bg-info ring-3 ring-info/20",
|
||||||
|
state === "paused" && "border-[1.5px] border-text-subtlest",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -165,7 +348,7 @@ function MenuRow({
|
|||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[1rem_minmax(0,1fr)_auto] gap-x-3 items-start px-1.5 py-1">
|
<div className="grid grid-cols-[1rem_minmax(0,1fr)_auto] gap-x-3 items-start px-1.5 py-1 w-[24rem] max-w-full">
|
||||||
<span className="h-5 flex items-center justify-center">{icon}</span>
|
<span className="h-5 flex items-center justify-center">{icon}</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-text">{title}</div>
|
<div className="text-sm text-text">{title}</div>
|
||||||
|
|||||||
@@ -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 { atom, useAtomValue } from "jotai";
|
||||||
import { atomWithStorage } from "jotai/utils";
|
import { atomWithStorage } from "jotai/utils";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { SIDEBAR_TREE_ID } from "../lib/atoms";
|
||||||
|
import { appKeyOf, appNodeId, sidebarPath } from "../lib/search";
|
||||||
import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse";
|
import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse";
|
||||||
import { flowsAtom, flowTotalAtom } from "../lib/store";
|
import { flowsAtom, flowTotalAtom } from "../lib/store";
|
||||||
|
import { AppIcon } from "./AppIcon";
|
||||||
|
|
||||||
/** A node in the sidebar tree — either a domain or a path segment. */
|
/** A node in the sidebar tree: a domain, a path segment, or an app. */
|
||||||
export type SidebarItem = {
|
export type SidebarItem = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
/** Flows held under this node; for the Domains folder, every flow in the database */
|
/** Flows held under this node; for the Domains folder, every flow in the database */
|
||||||
count: number;
|
count: number;
|
||||||
|
/** An app's path, for its icon */
|
||||||
|
appPath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SIDEBAR_TREE_ID = "proxy-sidebar";
|
|
||||||
|
|
||||||
const sidebarTreeAtom = atom<TreeNode<SidebarItem>>((get) => {
|
const sidebarTreeAtom = atom<TreeNode<SidebarItem>>((get) => {
|
||||||
const exchanges = get(flowsAtom);
|
const exchanges = get(flowsAtom);
|
||||||
return buildTree(exchanges, get(flowTotalAtom));
|
return buildTree(exchanges, get(flowTotalAtom));
|
||||||
@@ -43,34 +46,15 @@ const collapsedAtom = atom(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
/** The sidebar nodes selected, or null when the list shows all traffic */
|
|
||||||
const selectedNodeIdsAtom = atom((get) => {
|
|
||||||
const selectedIds = get(selectedIdsFamily(SIDEBAR_TREE_ID));
|
|
||||||
// Nothing selected, or a node holding everything → show all
|
|
||||||
if (selectedIds.length === 0 || selectedIds.includes("root") || selectedIds.includes("domains")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return new Set(selectedIds);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Exchanges filtered by the currently selected sidebar node(s). */
|
|
||||||
export const filteredExchangesAtom = atom((get) => {
|
|
||||||
const exchanges = get(flowsAtom);
|
|
||||||
const selected = get(selectedNodeIdsAtom);
|
|
||||||
if (selected == null) return exchanges;
|
|
||||||
return exchanges.filter((ex) => nodeIdsOf(ex).some((id) => selected.has(id)));
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Flows in the database for all traffic, or those held under the selected nodes */
|
|
||||||
export const filteredCountAtom = atom((get) =>
|
|
||||||
get(selectedNodeIdsAtom) == null ? get(flowTotalAtom) : get(filteredExchangesAtom).length,
|
|
||||||
);
|
|
||||||
|
|
||||||
/** The selected sidebar node's name, for the header */
|
/** The selected sidebar node's name, for the header */
|
||||||
export const selectedSidebarLabelAtom = atom((get) => {
|
export const selectedSidebarLabelAtom = atom((get) => {
|
||||||
const selectedId = get(selectedIdsFamily(SIDEBAR_TREE_ID))[0];
|
const selectedId = get(selectedIdsFamily(SIDEBAR_TREE_ID))[0];
|
||||||
if (selectedId?.startsWith("domain:")) return selectedId.slice("domain:".length);
|
if (selectedId?.startsWith("domain:")) return selectedId.slice("domain:".length);
|
||||||
if (selectedId?.startsWith("path:")) return selectedId.slice("path:".length);
|
if (selectedId?.startsWith("path:")) return selectedId.slice("path:".length);
|
||||||
|
if (selectedId?.startsWith("app:")) {
|
||||||
|
const apps = get(sidebarTreeAtom).children?.find((c) => c.item.id === "apps");
|
||||||
|
return apps?.children?.find((c) => c.item.id === selectedId)?.item.label ?? "All traffic";
|
||||||
|
}
|
||||||
return "All traffic";
|
return "All traffic";
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,21 +67,15 @@ const nodeIdsByFlow = new WeakMap<Flow, string[]>();
|
|||||||
function nodeIdsOf(ex: Flow): string[] {
|
function nodeIdsOf(ex: Flow): string[] {
|
||||||
let ids = nodeIdsByFlow.get(ex);
|
let ids = nodeIdsByFlow.get(ex);
|
||||||
if (ids != null) return ids;
|
if (ids != null) return ids;
|
||||||
let hostname: string;
|
// By the rules the database filters by, so a node shows the flows it counts.
|
||||||
let segments: string[];
|
const path = sidebarPath(ex.url);
|
||||||
try {
|
const schemeless = !ex.url.includes("://");
|
||||||
const url = new URL(ex.url);
|
const [hostname = "", ...segments] = schemeless ? [path] : path.split("/");
|
||||||
hostname = url.host;
|
|
||||||
segments = url.pathname.split("/").filter(Boolean);
|
|
||||||
} catch {
|
|
||||||
hostname = ex.url;
|
|
||||||
segments = [];
|
|
||||||
}
|
|
||||||
ids = [`domain:${hostname}`];
|
ids = [`domain:${hostname}`];
|
||||||
let path = `path:${hostname}`;
|
let node = `path:${hostname}`;
|
||||||
for (const seg of segments) {
|
for (const seg of segments) {
|
||||||
path += `/${seg}`;
|
node += `/${seg}`;
|
||||||
ids.push(path);
|
ids.push(node);
|
||||||
}
|
}
|
||||||
nodeIdsByFlow.set(ex, ids);
|
nodeIdsByFlow.set(ex, ids);
|
||||||
return ids;
|
return ids;
|
||||||
@@ -197,6 +175,34 @@ function buildTree(exchanges: Flow[], total: number): TreeNode<SidebarItem> {
|
|||||||
domainsFolder.children?.push(toTreeNode(domain, domainsFolder, 2));
|
domainsFolder.children?.push(toTreeNode(domain, domainsFolder, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The apps flows came from, where the platform can tell; none on others, so no group.
|
||||||
|
const apps = new Map<string, SidebarItem>();
|
||||||
|
for (const ex of exchanges) {
|
||||||
|
const key = appKeyOf(ex);
|
||||||
|
if (key == null) continue;
|
||||||
|
const id = appNodeId(key);
|
||||||
|
const app = apps.get(id);
|
||||||
|
if (app == null) {
|
||||||
|
apps.set(id, { id, label: ex.clientName ?? key, count: 1, appPath: ex.clientPath ?? key });
|
||||||
|
} else {
|
||||||
|
app.count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (apps.size > 0) {
|
||||||
|
const appsFolder: TreeNode<SidebarItem> = {
|
||||||
|
item: { id: "apps", label: "Apps", count: 0 },
|
||||||
|
parent: rootNode,
|
||||||
|
depth: 1,
|
||||||
|
children: [],
|
||||||
|
draggable: false,
|
||||||
|
};
|
||||||
|
const sortedApps = [...apps.values()].sort((a, b) => collator.compare(a.label, b.label));
|
||||||
|
for (const item of sortedApps) {
|
||||||
|
appsFolder.children?.push({ item, parent: appsFolder, depth: 2, draggable: false });
|
||||||
|
}
|
||||||
|
rootNode.children?.push(appsFolder);
|
||||||
|
}
|
||||||
|
|
||||||
rootNode.children?.push(domainsFolder);
|
rootNode.children?.push(domainsFolder);
|
||||||
|
|
||||||
return rootNode;
|
return rootNode;
|
||||||
@@ -206,6 +212,7 @@ function ItemInner({ item }: { item: SidebarItem }) {
|
|||||||
const count = item.count;
|
const count = item.count;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 w-full min-w-0">
|
<div className="flex items-center gap-2 w-full min-w-0">
|
||||||
|
{item.appPath != null && <AppIcon path={item.appPath} />}
|
||||||
<span className="truncate">{item.label}</span>
|
<span className="truncate">{item.label}</span>
|
||||||
{count > 0 && <span className="ml-auto text-text-subtlest text-2xs shrink-0">{count}</span>}
|
{count > 0 && <span className="ml-auto text-text-subtlest text-2xs shrink-0">{count}</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -214,9 +221,9 @@ function ItemInner({ item }: { item: SidebarItem }) {
|
|||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const tree = useAtomValue(sidebarTreeAtom);
|
const tree = useAtomValue(sidebarTreeAtom);
|
||||||
const treeId = SIDEBAR_TREE_ID;
|
|
||||||
|
|
||||||
const getItemKey = useCallback((item: SidebarItem) => `${item.id}:${item.count}`, []);
|
const getItemKey = useCallback((item: SidebarItem) => `${item.id}:${item.count}`, []);
|
||||||
|
const treeId = SIDEBAR_TREE_ID;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// The tree scrolls itself, in a row of bounded height, so its virtualizer mounts only the rows
|
// The tree scrolls itself, in a row of bounded height, so its virtualizer mounts only the rows
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Button, Icon } from "@yaakapp-internal/ui";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||||
|
import { proxyStateAtom } from "../lib/store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A quiet strip while another process, the CLI daemon, owns the engine: flows still list, read
|
||||||
|
* from the database, and Take over stops that process's proxy and starts this app's. Never done
|
||||||
|
* without the person asking.
|
||||||
|
*/
|
||||||
|
export function TakeOverBanner() {
|
||||||
|
const other = useAtomValue(proxyStateAtom)?.otherOwner;
|
||||||
|
const takeOver = useRpcMutation("take_over");
|
||||||
|
if (other == null) return null;
|
||||||
|
const since = new Date(other.startedAt).toLocaleTimeString([], {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
const cli = other.owner === "cli";
|
||||||
|
const who = cli ? "Running from the CLI" : "Running in another Yaak Proxy";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2.5 h-[2.2rem] mx-2 mb-1 pl-3 pr-1.5 rounded-md bg-surface-highlight/45 border border-border-subtle text-sm text-text-subtle min-w-0">
|
||||||
|
<Icon icon="square_terminal" size="sm" color="info" className="shrink-0" />
|
||||||
|
<span className="text-text shrink-0">{who}</span>
|
||||||
|
{other.owner === "cli" && (
|
||||||
|
<span className="font-mono text-xs text-text-subtlest shrink-0">yaak proxy start</span>
|
||||||
|
)}
|
||||||
|
<span className="truncate">
|
||||||
|
{!cli ? (
|
||||||
|
// Stopping another app halts its engine without releasing it, so it can't be taken over.
|
||||||
|
"Another Yaak Proxy window owns the engine. Quit it to use this one."
|
||||||
|
) : takeOver.error != null ? (
|
||||||
|
<span className="text-danger">{takeOver.error.message}</span>
|
||||||
|
) : (
|
||||||
|
`pid ${other.pid}, since ${since}. Flows still list here, read from the capture database.`
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{cli && (
|
||||||
|
<Button
|
||||||
|
size="2xs"
|
||||||
|
variant="border"
|
||||||
|
className="ml-auto shrink-0"
|
||||||
|
isLoading={takeOver.isPending}
|
||||||
|
onClick={() => takeOver.mutate({})}
|
||||||
|
>
|
||||||
|
Take over
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ import type { SplitLayoutLayout } from "@yaakapp-internal/ui";
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { atomWithStorage } from "jotai/utils";
|
import { atomWithStorage } from "jotai/utils";
|
||||||
|
|
||||||
|
/** The sidebar's tree, whose selection narrows the list */
|
||||||
|
export const SIDEBAR_TREE_ID = "proxy-sidebar";
|
||||||
|
|
||||||
/** The selected flow as last seen, kept once the window has dropped it so the detail stays */
|
/** The selected flow as last seen, kept once the window has dropped it so the detail stays */
|
||||||
export const selectedFlowAtom = atom<Flow | null>(null);
|
export const selectedFlowAtom = atom<Flow | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -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 { atom, type createStore } from "jotai";
|
||||||
import { followingAtom, selectedFlowAtom } from "./atoms";
|
import { followingAtom, SIDEBAR_TREE_ID, selectedFlowAtom } from "./atoms";
|
||||||
import {
|
import {
|
||||||
applyChanges,
|
applyChanges,
|
||||||
emptyWindow,
|
emptyWindow,
|
||||||
@@ -12,11 +13,18 @@ import {
|
|||||||
windowFromPage,
|
windowFromPage,
|
||||||
} from "./flowWindow";
|
} from "./flowWindow";
|
||||||
import { listen, rpc } from "./rpc";
|
import { listen, rpc } from "./rpc";
|
||||||
|
import { type FlowQuery, matchesQuery, selectionQuery } from "./search";
|
||||||
|
|
||||||
|
type Store = ReturnType<typeof createStore>;
|
||||||
|
|
||||||
/** How long new flows gather before they reach the list, so a burst renders once */
|
/** How long new flows gather before they reach the list, so a burst renders once */
|
||||||
const FLUSH_MS = 100;
|
const FLUSH_MS = 100;
|
||||||
/** How long after new flows the total is counted again from the database */
|
/** How long after new flows the total is counted again from the database */
|
||||||
const RECOUNT_MS = 1000;
|
const RECOUNT_MS = 1000;
|
||||||
|
/** How often the database is read for new flows while another process owns the engine */
|
||||||
|
const POLL_MS = 1000;
|
||||||
|
/** How often the engine's owner is checked while it is another process */
|
||||||
|
const OWNER_POLL_MS = 2000;
|
||||||
|
|
||||||
/** The flows in memory: a window onto the database, which keeps them all */
|
/** The flows in memory: a window onto the database, which keeps them all */
|
||||||
export const flowWindowAtom = atom<FlowWindow>(emptyWindow);
|
export const flowWindowAtom = atom<FlowWindow>(emptyWindow);
|
||||||
@@ -27,23 +35,73 @@ export const flowsAtom = atom((get) => get(flowWindowAtom).flows);
|
|||||||
/** Start of the newest flow held, across all traffic */
|
/** Start of the newest flow held, across all traffic */
|
||||||
export const newestStartedAtAtom = atom((get) => get(flowsAtom).at(-1)?.startedAt ?? null);
|
export const newestStartedAtAtom = atom((get) => get(flowsAtom).at(-1)?.startedAt ?? null);
|
||||||
|
|
||||||
/** Start and id of the oldest flow held, which loading older flows moves back */
|
|
||||||
export const oldestFlowAtom = atom((get) => get(flowsAtom)[0] ?? null);
|
|
||||||
|
|
||||||
export const hasOlderFlowsAtom = atom((get) => get(flowWindowAtom).hasOlder);
|
|
||||||
|
|
||||||
/** Flows in the database */
|
/** Flows in the database */
|
||||||
export const flowTotalAtom = atom((get) => get(flowWindowAtom).total);
|
export const flowTotalAtom = atom((get) => get(flowWindowAtom).total);
|
||||||
|
|
||||||
|
/** The first page of flows has loaded, so an empty window means an empty database */
|
||||||
|
export const flowsLoadedAtom = atom(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The database has no flows and nothing narrows the list, so the first-run card shows. A boolean
|
||||||
|
* of its own, so new traffic does not re-render what reads it.
|
||||||
|
*/
|
||||||
|
export const showFirstRunAtom = atom(
|
||||||
|
(get) => get(flowsLoadedAtom) && get(flowTotalAtom) === 0 && get(activeQueryAtom) == null,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Some flow held names the app that sent it, which only some platforms can tell */
|
||||||
|
export const hasAppsAtom = atom((get) => get(flowsAtom).some((f) => f.clientName != null));
|
||||||
|
|
||||||
|
/** The search box's terms, once typing settles, or null */
|
||||||
|
export const searchTermsAtom = atom<string | null>(null);
|
||||||
|
|
||||||
|
/** The search the list shows, from the search box and the sidebar, or null for all traffic */
|
||||||
|
export const activeQueryAtom = atom<FlowQuery | null>((get) => {
|
||||||
|
const q = get(searchTermsAtom);
|
||||||
|
const selection = selectionQuery(get(selectedIdsFamily(SIDEBAR_TREE_ID)));
|
||||||
|
if (q == null && selection == null) return null;
|
||||||
|
return { ...(q == null ? {} : { q }), ...selection };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A window onto the flows a search covers, loaded from the database as the main one is */
|
||||||
|
export interface SearchWindow {
|
||||||
|
key: string;
|
||||||
|
query: FlowQuery;
|
||||||
|
win: FlowWindow;
|
||||||
|
/** The first page has not arrived yet */
|
||||||
|
loading: boolean;
|
||||||
|
/** Changes that came while the first page loaded, applied once it is in */
|
||||||
|
buffered: ModelPayload[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchWindowAtom = atom<SearchWindow | null>(null);
|
||||||
|
|
||||||
|
/** The window the list shows: the search's, or all traffic's */
|
||||||
|
const shownWindowAtom = atom((get) => get(searchWindowAtom)?.win ?? get(flowWindowAtom));
|
||||||
|
|
||||||
|
/** The flows the list shows, oldest first */
|
||||||
|
export const shownFlowsAtom = atom((get) => get(shownWindowAtom).flows);
|
||||||
|
|
||||||
|
/** Flows in the database the list covers: every one, or those the search matches */
|
||||||
|
export const shownTotalAtom = atom((get) => get(shownWindowAtom).total);
|
||||||
|
|
||||||
|
/** Start and id of the oldest flow shown, which loading older flows moves back */
|
||||||
|
export const oldestFlowAtom = atom((get) => get(shownFlowsAtom)[0] ?? null);
|
||||||
|
|
||||||
/** Follows new traffic, dropping the older flows scrolling up loaded, or holds the list */
|
/** Follows new traffic, dropping the older flows scrolling up loaded, or holds the list */
|
||||||
export const followAtom = atom(
|
export const followAtom = atom(
|
||||||
(get) => get(followingAtom),
|
(get) => get(followingAtom),
|
||||||
(_get, set, following: boolean) => {
|
(_get, set, following: boolean) => {
|
||||||
set(followingAtom, following);
|
set(followingAtom, following);
|
||||||
if (following) set(flowWindowAtom, (win) => trimWindow(win));
|
if (!following) return;
|
||||||
|
set(flowWindowAtom, (win) => trimWindow(win));
|
||||||
|
set(searchWindowAtom, (s) => (s == null ? s : { ...s, win: trimWindow(s.win) }));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** The engine's state, kept current from its events, and polled while another process has it */
|
||||||
|
export const proxyStateAtom = atom<GetProxyStateResponse | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Changes applied while a page of older flows loads, or null when none is loading. A slow flow
|
* Changes applied while a page of older flows loads, or null when none is loading. A slow flow
|
||||||
* that ends meanwhile may be older than the window, so left out of it, and missing from a page
|
* that ends meanwhile may be older than the window, so left out of it, and missing from a page
|
||||||
@@ -51,22 +109,29 @@ export const followAtom = atom(
|
|||||||
*/
|
*/
|
||||||
let changesDuringPage: ModelPayload[] | null = null;
|
let changesDuringPage: ModelPayload[] | null = null;
|
||||||
|
|
||||||
/** Loads the page of flows before the oldest one held */
|
/** Loads the page of flows before the oldest one shown, from the search's flows when searching */
|
||||||
export const loadOlderFlowsAtom = atom(null, async (get, set) => {
|
export const loadOlderFlowsAtom = atom(null, async (get, set) => {
|
||||||
const { flows, hasOlder } = get(flowWindowAtom);
|
const search = get(searchWindowAtom);
|
||||||
|
const { flows, hasOlder } = search?.win ?? get(flowWindowAtom);
|
||||||
const first = flows[0];
|
const first = flows[0];
|
||||||
if (changesDuringPage != null || !hasOlder || first == null) return;
|
if (changesDuringPage != null || !hasOlder || first == null) return;
|
||||||
const changes: ModelPayload[] = [];
|
const changes: ModelPayload[] = [];
|
||||||
changesDuringPage = changes;
|
changesDuringPage = changes;
|
||||||
try {
|
try {
|
||||||
const res = await rpc("list_flows", { before: first.id, limit: PAGE_SIZE });
|
const res = await rpc("list_flows", { ...search?.query, before: first.id, limit: PAGE_SIZE });
|
||||||
// Dropped when the window moved on meanwhile, trimmed or cleared.
|
// Dropped when the window moved on meanwhile, trimmed, cleared or searched again.
|
||||||
set(flowWindowAtom, (win) => {
|
const merge = (win: FlowWindow) => {
|
||||||
if (win.flows[0]?.id !== first.id) return win;
|
if (win.flows[0]?.id !== first.id) return win;
|
||||||
const merged = prependOlder(win, res.flows, PAGE_SIZE);
|
const merged = prependOlder(win, res.flows, PAGE_SIZE);
|
||||||
|
const kept = search == null ? changes : changesFor(search.query, changes);
|
||||||
// Counted when they first applied.
|
// Counted when they first applied.
|
||||||
return { ...applyChanges(merged, changes), total: merged.total };
|
return { ...applyChanges(merged, kept), total: merged.total };
|
||||||
});
|
};
|
||||||
|
if (search == null) {
|
||||||
|
set(flowWindowAtom, merge);
|
||||||
|
} else {
|
||||||
|
set(searchWindowAtom, (s) => (s?.key === search.key ? { ...s, win: merge(s.win) } : s));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Failed to load older flows", err);
|
console.warn("Failed to load older flows", err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -74,16 +139,26 @@ export const loadOlderFlowsAtom = atom(null, async (get, set) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The changes a search's window takes: flows it matches, and every deletion */
|
||||||
|
function changesFor(query: FlowQuery, changes: ModelPayload[]): ModelPayload[] {
|
||||||
|
return changes.filter((c) => c.change.type === "delete" || matchesQuery(c.model, query));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the newest flows, then keeps them current with the engine's events. Events gather for
|
* Loads the newest flows, then keeps them current with the engine's events, or with reads of the
|
||||||
* `FLUSH_MS` and apply together, and the window drops its oldest flows while the list follows,
|
* database while another process owns the engine. Events gather for `FLUSH_MS` and apply
|
||||||
* so a new flow costs the same however many the database holds.
|
* together, and the window drops its oldest flows while the list follows, so a new flow costs the
|
||||||
|
* same however many the database holds. A search gets a window of its own, kept current the same
|
||||||
|
* way.
|
||||||
*/
|
*/
|
||||||
export function syncFlows(store: ReturnType<typeof createStore>) {
|
export function syncFlows(store: Store) {
|
||||||
let pending: ModelPayload[] = [];
|
let pending: ModelPayload[] = [];
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let loaded = false;
|
let loaded = false;
|
||||||
let loads = 0;
|
let loads = 0;
|
||||||
|
let searchLoads = 0;
|
||||||
|
/** A flow written at or before the newest the app has seen, for reading new ones after it */
|
||||||
|
let seenId: string | null = null;
|
||||||
|
|
||||||
function flush() {
|
function flush() {
|
||||||
timer = null;
|
timer = null;
|
||||||
@@ -91,9 +166,17 @@ export function syncFlows(store: ReturnType<typeof createStore>) {
|
|||||||
const changes = pending;
|
const changes = pending;
|
||||||
pending = [];
|
pending = [];
|
||||||
changesDuringPage?.push(...changes);
|
changesDuringPage?.push(...changes);
|
||||||
|
const following = store.get(followingAtom);
|
||||||
store.set(flowWindowAtom, (win) => {
|
store.set(flowWindowAtom, (win) => {
|
||||||
const next = applyChanges(win, changes);
|
const next = applyChanges(win, changes);
|
||||||
return store.get(followingAtom) ? trimWindow(next) : next;
|
return following ? trimWindow(next) : next;
|
||||||
|
});
|
||||||
|
store.set(searchWindowAtom, (s) => {
|
||||||
|
if (s == null) return s;
|
||||||
|
// A matching flow written after the page's snapshot would otherwise be missed.
|
||||||
|
if (s.loading) return { ...s, buffered: [...s.buffered, ...changes] };
|
||||||
|
const next = applyChanges(s.win, changesFor(s.query, changes));
|
||||||
|
return { ...s, win: following ? trimWindow(next) : next };
|
||||||
});
|
});
|
||||||
const selected = store.get(selectedFlowAtom);
|
const selected = store.get(selectedFlowAtom);
|
||||||
const change = changes.findLast((c) => c.model.id === selected?.id);
|
const change = changes.findLast((c) => c.model.id === selected?.id);
|
||||||
@@ -103,6 +186,11 @@ export function syncFlows(store: ReturnType<typeof createStore>) {
|
|||||||
if (recountTimer == null) recountTimer = setTimeout(recount, RECOUNT_MS);
|
if (recountTimer == null) recountTimer = setTimeout(recount, RECOUNT_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function receive(changes: ModelPayload[]) {
|
||||||
|
pending.push(...changes);
|
||||||
|
if (timer == null) timer = setTimeout(flush, FLUSH_MS);
|
||||||
|
}
|
||||||
|
|
||||||
// The total counts each new flow as it comes, which a flow both in a page and in the events
|
// The total counts each new flow as it comes, which a flow both in a page and in the events
|
||||||
// around it can count twice or not at all; a count from the database a moment later settles it.
|
// around it can count twice or not at all; a count from the database a moment later settles it.
|
||||||
let recountTimer: ReturnType<typeof setTimeout> | null = null;
|
let recountTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -115,11 +203,19 @@ export function syncFlows(store: ReturnType<typeof createStore>) {
|
|||||||
win.total === res.total ? win : { ...win, total: res.total },
|
win.total === res.total ? win : { ...win, total: res.total },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
const search = store.get(searchWindowAtom);
|
||||||
|
if (search == null || search.loading) return;
|
||||||
|
void rpc("list_flows", { ...search.query, limit: 0 }).then((res) => {
|
||||||
|
const total = res.matching ?? res.total;
|
||||||
|
store.set(searchWindowAtom, (s) =>
|
||||||
|
s?.key !== search.key || s.win.total === total ? s : { ...s, win: { ...s.win, total } },
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
listen("model_write", (payload) => {
|
listen("model_write", (payload) => {
|
||||||
pending.push(payload);
|
seenId = payload.model.id;
|
||||||
if (timer == null) timer = setTimeout(flush, FLUSH_MS);
|
receive([payload]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Loads again rather than only emptying, for a flow written between the delete and the event.
|
// Loads again rather than only emptying, for a flow written between the delete and the event.
|
||||||
@@ -138,10 +234,123 @@ export function syncFlows(store: ReturnType<typeof createStore>) {
|
|||||||
// A clear since asked for a newer page.
|
// A clear since asked for a newer page.
|
||||||
if (thisLoad !== loads) return;
|
if (thisLoad !== loads) return;
|
||||||
store.set(flowWindowAtom, windowFromPage(res.flows, WINDOW_SIZE, res.total));
|
store.set(flowWindowAtom, windowFromPage(res.flows, WINDOW_SIZE, res.total));
|
||||||
|
seenId = res.flows[0]?.id ?? null;
|
||||||
loaded = true;
|
loaded = true;
|
||||||
|
store.set(flowsLoadedAtom, true);
|
||||||
flush();
|
flush();
|
||||||
});
|
});
|
||||||
|
loadSearch(store.get(activeQueryAtom));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Loads the first page of `query`'s flows into a window of its own, or drops it for null */
|
||||||
|
function loadSearch(query: FlowQuery | null) {
|
||||||
|
if (query == null) {
|
||||||
|
store.set(searchWindowAtom, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Unique to this load, so a slower answer to an earlier one with the same query, such as
|
||||||
|
// one read before a clear, is dropped.
|
||||||
|
const key = `${++searchLoads}:${JSON.stringify(query)}`;
|
||||||
|
store.set(searchWindowAtom, { key, query, win: emptyWindow, loading: true, buffered: [] });
|
||||||
|
const settle = (page: FlowWindow | null) =>
|
||||||
|
store.set(searchWindowAtom, (s) => {
|
||||||
|
if (s?.key !== key) return s;
|
||||||
|
// Applied again over the page: a flow already in it is replaced, not added.
|
||||||
|
const win = applyChanges(page ?? s.win, changesFor(query, s.buffered));
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
win: page == null ? win : { ...win, total: page.total },
|
||||||
|
loading: false,
|
||||||
|
buffered: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
void rpc("list_flows", { ...query, limit: WINDOW_SIZE })
|
||||||
|
.then((res) => settle(windowFromPage(res.flows, WINDOW_SIZE, res.matching ?? res.total)))
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn("Failed to search flows", err);
|
||||||
|
settle(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeKey = JSON.stringify(store.get(activeQueryAtom));
|
||||||
|
store.sub(activeQueryAtom, () => {
|
||||||
|
const query = store.get(activeQueryAtom);
|
||||||
|
const key = JSON.stringify(query);
|
||||||
|
if (key === activeKey) return;
|
||||||
|
activeKey = key;
|
||||||
|
loadSearch(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
// While another process owns the engine no events come, so new flows are read from the
|
||||||
|
// database. Reading after a flow that started last can return a slow one already seen, which
|
||||||
|
// applies as an update; it never skips one.
|
||||||
|
let polling = false;
|
||||||
|
function readNewFlows() {
|
||||||
|
if (polling || !loaded) return;
|
||||||
|
polling = true;
|
||||||
|
const after = seenId;
|
||||||
|
const read =
|
||||||
|
after == null
|
||||||
|
? rpc("list_flows", { limit: WINDOW_SIZE })
|
||||||
|
: rpc("list_flows", { after, limit: WINDOW_SIZE });
|
||||||
|
read
|
||||||
|
.then((res) => {
|
||||||
|
if (res.flows.length === 0) return;
|
||||||
|
// More arrived than one read holds: load the newest again rather than leave a gap.
|
||||||
|
if (res.flows.length >= WINDOW_SIZE) {
|
||||||
|
load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (seenId === after) seenId = res.flows[0]?.id ?? seenId;
|
||||||
|
const known = new Set(store.get(flowsAtom).map((f) => f.id));
|
||||||
|
receive(
|
||||||
|
[...res.flows].reverse().map((model) => ({
|
||||||
|
model,
|
||||||
|
change: { type: "upsert", created: !known.has(model.id) },
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
// The flow read after was cleared: start again from the newest.
|
||||||
|
.catch(() => {
|
||||||
|
seenId = null;
|
||||||
|
load();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
polling = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setInterval(() => {
|
||||||
|
if (store.get(proxyStateAtom)?.otherOwner != null) readNewFlows();
|
||||||
|
}, POLL_MS);
|
||||||
|
// One last read once another owner lets go, for flows it wrote after the last one.
|
||||||
|
let hadOtherOwner = store.get(proxyStateAtom)?.otherOwner != null;
|
||||||
|
store.sub(proxyStateAtom, () => {
|
||||||
|
const hasOtherOwner = store.get(proxyStateAtom)?.otherOwner != null;
|
||||||
|
if (hadOtherOwner && !hasOtherOwner) readNewFlows();
|
||||||
|
hadOtherOwner = hasOtherOwner;
|
||||||
|
});
|
||||||
|
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps `proxyStateAtom` current: on each state change, and polled while the engine is not
|
||||||
|
* running here, so another process taking it or letting it go shows.
|
||||||
|
*/
|
||||||
|
export function syncProxyState(store: Store) {
|
||||||
|
function refresh() {
|
||||||
|
rpc("get_proxy_state", {})
|
||||||
|
.then((state) => store.set(proxyStateAtom, state))
|
||||||
|
.catch((err) => console.warn("Failed to read the proxy state", err));
|
||||||
|
}
|
||||||
|
listen("proxy_state_changed", refresh);
|
||||||
|
setInterval(() => {
|
||||||
|
if (store.get(proxyStateAtom)?.state !== "running") refresh();
|
||||||
|
}, OWNER_POLL_MS);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the proxy state again, after an action that changes it without an event */
|
||||||
|
export async function refreshProxyState(store: Store) {
|
||||||
|
store.set(proxyStateAtom, await rpc("get_proxy_state", {}));
|
||||||
|
}
|
||||||
@@ -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 { createRoot } from "react-dom/client";
|
||||||
import { ProxyLayout } from "./components/ProxyLayout";
|
import { ProxyLayout } from "./components/ProxyLayout";
|
||||||
import { useHotkeyContextValue } from "./lib/hotkeys";
|
import { useHotkeyContextValue } from "./lib/hotkeys";
|
||||||
import { syncFlows } from "./lib/store";
|
import { syncFlows, syncProxyState } from "./lib/store";
|
||||||
import "./main.css";
|
import "./main.css";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
const jotaiStore = createStore();
|
const jotaiStore = createStore();
|
||||||
|
|
||||||
|
syncProxyState(jotaiStore);
|
||||||
syncFlows(jotaiStore);
|
syncFlows(jotaiStore);
|
||||||
|
|
||||||
function Hotkeys({ children }: { children: ReactNode }) {
|
function Hotkeys({ children }: { children: ReactNode }) {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"@tanstack/react-query": "^5.104.2",
|
"@tanstack/react-query": "^5.104.2",
|
||||||
"@tanstack/react-virtual": "^3.14.17",
|
"@tanstack/react-virtual": "^3.14.17",
|
||||||
"@tauri-apps/api": "^2.12.3",
|
"@tauri-apps/api": "^2.12.3",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.8.2",
|
||||||
|
"@tauri-apps/plugin-opener": "^2.7.1",
|
||||||
"@tauri-apps/plugin-os": "^2.4.0",
|
"@tauri-apps/plugin-os": "^2.4.0",
|
||||||
"@yaakapp-internal/proxy-lib": "^1.0.0",
|
"@yaakapp-internal/proxy-lib": "^1.0.0",
|
||||||
"@yaakapp-internal/theme": "^1.0.0",
|
"@yaakapp-internal/theme": "^1.0.0",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ include_dir = "0.7"
|
|||||||
r2d2 = "0.8.10"
|
r2d2 = "0.8.10"
|
||||||
r2d2_sqlite = "0.32"
|
r2d2_sqlite = "0.32"
|
||||||
reqwest = { workspace = true, features = ["json"] }
|
reqwest = { workspace = true, features = ["json"] }
|
||||||
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
|
rusqlite = { version = "0.38", features = ["bundled", "chrono", "functions"] }
|
||||||
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
|
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
@@ -37,6 +37,27 @@ yaak-templates = { workspace = true }
|
|||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
objc2 = "0.6"
|
||||||
|
objc2-app-kit = { version = "0.3.2", default-features = false, features = [
|
||||||
|
"std",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"NSBitmapImageRep",
|
||||||
|
"NSGraphics",
|
||||||
|
"NSGraphicsContext",
|
||||||
|
"NSImage",
|
||||||
|
"NSImageRep",
|
||||||
|
"NSWorkspace",
|
||||||
|
] }
|
||||||
|
objc2-foundation = { version = "0.3.2", default-features = false, features = [
|
||||||
|
"std",
|
||||||
|
"NSData",
|
||||||
|
"NSDictionary",
|
||||||
|
"NSGeometry",
|
||||||
|
"NSString",
|
||||||
|
] }
|
||||||
|
plist = "1.7"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
base64 = "0.23.1"
|
base64 = "0.23.1"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
|
|||||||
+17
-1
@@ -44,7 +44,23 @@ elapsedMs: number | null, error: string | null,
|
|||||||
/**
|
/**
|
||||||
* The id of the Yaak request this flow was last saved as.
|
* The id of the Yaak request this flow was last saved as.
|
||||||
*/
|
*/
|
||||||
savedRequestId: string | null, };
|
savedRequestId: string | null,
|
||||||
|
/**
|
||||||
|
* The process that opened the client connection, where the platform can tell (macOS).
|
||||||
|
*/
|
||||||
|
clientPid: number | null,
|
||||||
|
/**
|
||||||
|
* Its app's name, or its executable's outside an app: "Google Chrome", "curl".
|
||||||
|
*/
|
||||||
|
clientName: string | null,
|
||||||
|
/**
|
||||||
|
* Its app's bundle id.
|
||||||
|
*/
|
||||||
|
clientBundleId: string | null,
|
||||||
|
/**
|
||||||
|
* Its app bundle's path, or its executable's outside an app.
|
||||||
|
*/
|
||||||
|
clientPath: string | null, };
|
||||||
|
|
||||||
export type FlowBody = "request" | "response";
|
export type FlowBody = "request" | "response";
|
||||||
|
|
||||||
|
|||||||
+153
-4
@@ -5,6 +5,12 @@ export type ActionInvocation = { "scope": "global", action: GlobalAction, };
|
|||||||
|
|
||||||
export type ActionMetadata = { label: string, defaultHotkey: string | null, };
|
export type ActionMetadata = { label: string, defaultHotkey: string | null, };
|
||||||
|
|
||||||
|
export type AppIconRequest = {
|
||||||
|
/**
|
||||||
|
* A flow's `clientPath`.
|
||||||
|
*/
|
||||||
|
path: string, };
|
||||||
|
|
||||||
export type CaStatus = {
|
export type CaStatus = {
|
||||||
/**
|
/**
|
||||||
* macOS trusts this app's CA, so HTTPS flows can be decrypted.
|
* macOS trusts this app's CA, so HTTPS flows can be decrypted.
|
||||||
@@ -21,6 +27,37 @@ export type CaTrustRequest = Record<string, never>;
|
|||||||
|
|
||||||
export type ClearFlowsRequest = Record<string, never>;
|
export type ClearFlowsRequest = Record<string, never>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app behind a client connection.
|
||||||
|
*/
|
||||||
|
export type ClientApp = { pid: number,
|
||||||
|
/**
|
||||||
|
* The app's name ("Google Chrome"), or the executable's for a process outside an app ("curl").
|
||||||
|
*/
|
||||||
|
name: string,
|
||||||
|
/**
|
||||||
|
* The outermost `.app` bundle's CFBundleIdentifier, if any.
|
||||||
|
*/
|
||||||
|
bundleId: string | null,
|
||||||
|
/**
|
||||||
|
* The outermost `.app` bundle's path, or the executable's path when there is no bundle.
|
||||||
|
*/
|
||||||
|
path: string, };
|
||||||
|
|
||||||
|
export type DropReason = "hop_by_hop" | "set_by_sending" | "body_decoded";
|
||||||
|
|
||||||
|
export type DroppedHeader = { name: string, value: string, reason: DropReason, };
|
||||||
|
|
||||||
|
export type ExportHarRequest = {
|
||||||
|
/**
|
||||||
|
* The file to write.
|
||||||
|
*/
|
||||||
|
path: string,
|
||||||
|
/**
|
||||||
|
* These flows, or else every flow the filter covers.
|
||||||
|
*/
|
||||||
|
ids?: Array<string>, filter?: FlowFilter, };
|
||||||
|
|
||||||
export type FilterBodyRequest = { flowId: string, which: FlowBody,
|
export type FilterBodyRequest = { flowId: string, which: FlowBody,
|
||||||
/**
|
/**
|
||||||
* JSONPath, or XPath for an XML or HTML body.
|
* JSONPath, or XPath for an XML or HTML body.
|
||||||
@@ -39,10 +76,25 @@ export type FilterLanguage = "jsonpath" | "xpath";
|
|||||||
* Which flows a listing or a HAR export covers. Every field narrows it; none is all of them.
|
* Which flows a listing or a HAR export covers. Every field narrows it; none is all of them.
|
||||||
*/
|
*/
|
||||||
export type FlowFilter = {
|
export type FlowFilter = {
|
||||||
|
/**
|
||||||
|
* Search terms, any case, each of which must match the URL (host, path or query), the
|
||||||
|
* method, the status (`404`, `40`, `4xx`) or the content type: `stripe post 4xx`.
|
||||||
|
*/
|
||||||
|
q?: string,
|
||||||
/**
|
/**
|
||||||
* Part of the host, any case: `stripe` matches `api.stripe.com`.
|
* Part of the host, any case: `stripe` matches `api.stripe.com`.
|
||||||
*/
|
*/
|
||||||
host?: string,
|
host?: string,
|
||||||
|
/**
|
||||||
|
* Only flows under one of these hosts or paths, as the app's sidebar names them:
|
||||||
|
* `api.stripe.com` or `api.stripe.com/v1/charges`, with any port after the host.
|
||||||
|
*/
|
||||||
|
under?: Array<string>,
|
||||||
|
/**
|
||||||
|
* Only flows from one of these apps, by bundle id, or by path for a process outside an
|
||||||
|
* app bundle.
|
||||||
|
*/
|
||||||
|
app?: Array<string>,
|
||||||
/**
|
/**
|
||||||
* The method, any case.
|
* The method, any case.
|
||||||
*/
|
*/
|
||||||
@@ -68,6 +120,12 @@ before?: string, };
|
|||||||
|
|
||||||
export type FlowsClearedPayload = Record<string, never>;
|
export type FlowsClearedPayload = Record<string, never>;
|
||||||
|
|
||||||
|
export type FolderSummary = { id: string, name: string,
|
||||||
|
/**
|
||||||
|
* The folder this one is in, if any.
|
||||||
|
*/
|
||||||
|
folderId: string | null, };
|
||||||
|
|
||||||
export type FormatBodyRequest = { text: string, };
|
export type FormatBodyRequest = { text: string, };
|
||||||
|
|
||||||
export type GetFlowBodyRequest = { flowId: string, which: FlowBody, };
|
export type GetFlowBodyRequest = { flowId: string, which: FlowBody, };
|
||||||
@@ -92,9 +150,30 @@ export type GetProxyStateResponse = { state: ProxyState,
|
|||||||
/**
|
/**
|
||||||
* The port the engine is bound to, while it runs.
|
* The port the engine is bound to, while it runs.
|
||||||
*/
|
*/
|
||||||
port: number | null, };
|
port: number | null,
|
||||||
|
/**
|
||||||
|
* The port the engine starts on.
|
||||||
|
*/
|
||||||
|
configuredPort: number,
|
||||||
|
/**
|
||||||
|
* Flows are stored. Paused, traffic still passes through, unrecorded.
|
||||||
|
*/
|
||||||
|
recording: boolean,
|
||||||
|
/**
|
||||||
|
* The system proxy turns on as the engine starts.
|
||||||
|
*/
|
||||||
|
systemProxyAtLaunch: boolean,
|
||||||
|
/**
|
||||||
|
* Why the engine last failed to start, until it starts.
|
||||||
|
*/
|
||||||
|
startError: string | null,
|
||||||
|
/**
|
||||||
|
* Another process that owns the engine, such as the CLI daemon. While one does, this app
|
||||||
|
* cannot start its own, and new flows reach it only through the database.
|
||||||
|
*/
|
||||||
|
otherOwner: OtherOwner | null, };
|
||||||
|
|
||||||
export type GlobalAction = "proxy_start" | "proxy_stop" | "flows_clear";
|
export type GlobalAction = "recording_toggle" | "flows_clear";
|
||||||
|
|
||||||
export type ListActionsRequest = Record<string, never>;
|
export type ListActionsRequest = Record<string, never>;
|
||||||
|
|
||||||
@@ -108,13 +187,33 @@ flows: Array<Flow>,
|
|||||||
/**
|
/**
|
||||||
* Flows in the database, whatever the filter.
|
* Flows in the database, whatever the filter.
|
||||||
*/
|
*/
|
||||||
total: number, };
|
total: number,
|
||||||
|
/**
|
||||||
|
* Flows the filter covers, whatever the limit, when it narrows to fewer than all of them.
|
||||||
|
*/
|
||||||
|
matching: number | null, };
|
||||||
|
|
||||||
|
export type ListWorkspacesRequest = Record<string, never>;
|
||||||
|
|
||||||
|
export type ListWorkspacesResponse = {
|
||||||
|
/**
|
||||||
|
* Yaak has run on this machine. Without it there is nothing to save into.
|
||||||
|
*/
|
||||||
|
yaakFound: boolean, workspaces: Array<WorkspaceSummary>, };
|
||||||
|
|
||||||
|
export type OtherOwner = { owner: Owner, pid: number, startedAt: string,
|
||||||
|
/**
|
||||||
|
* The engine's port while it runs.
|
||||||
|
*/
|
||||||
|
proxyPort: number | null, };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The process that runs the engine.
|
* The process that runs the engine.
|
||||||
*/
|
*/
|
||||||
export type Owner = "app" | "cli";
|
export type Owner = "app" | "cli";
|
||||||
|
|
||||||
|
export type PreviewSaveRequest = { flowId: string, };
|
||||||
|
|
||||||
export type ProxySetting = { enabled: boolean, server: string, port: number, };
|
export type ProxySetting = { enabled: boolean, server: string, port: number, };
|
||||||
|
|
||||||
export type ProxyState = "running" | "stopped";
|
export type ProxyState = "running" | "stopped";
|
||||||
@@ -123,7 +222,49 @@ export type ProxyStatePayload = { state: ProxyState, };
|
|||||||
|
|
||||||
export type RpcEventSchema = { flows_cleared: FlowsClearedPayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, };
|
export type RpcEventSchema = { flows_cleared: FlowsClearedPayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, };
|
||||||
|
|
||||||
export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], };
|
export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], save_flow: [SaveFlowToYaakRequest, SavedFlow], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], };
|
||||||
|
|
||||||
|
export type SaveFlowToYaakRequest = { flowId: string, workspaceId: string, folderId?: string,
|
||||||
|
/**
|
||||||
|
* None or blank leaves the request unnamed, and Yaak shows its URL.
|
||||||
|
*/
|
||||||
|
name?: string, };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What saving a flow as a Yaak request writes, for the save dialog.
|
||||||
|
*/
|
||||||
|
export type SavePreview = { method: string, url: string,
|
||||||
|
/**
|
||||||
|
* The URL as saved, its query split out into parameters, which Yaak shows for an unnamed
|
||||||
|
* request.
|
||||||
|
*/
|
||||||
|
requestUrl: string,
|
||||||
|
/**
|
||||||
|
* The saved body type, which Yaak shows for an unnamed GraphQL request with no URL.
|
||||||
|
*/
|
||||||
|
bodyType: string | null,
|
||||||
|
/**
|
||||||
|
* Headers Yaak sets itself or that only meant something to the proxy.
|
||||||
|
*/
|
||||||
|
droppedHeaders: Array<DroppedHeader>,
|
||||||
|
/**
|
||||||
|
* Headers saved as captured, Authorization among them.
|
||||||
|
*/
|
||||||
|
keptHeaders: number,
|
||||||
|
/**
|
||||||
|
* The body as it is saved: "JSON", "form URL-encoded with 7 fields". None without a body.
|
||||||
|
*/
|
||||||
|
body: string | null,
|
||||||
|
/**
|
||||||
|
* What does not carry over exactly, one sentence each.
|
||||||
|
*/
|
||||||
|
warnings: Array<string>,
|
||||||
|
/**
|
||||||
|
* An Authorization header is saved in plain text.
|
||||||
|
*/
|
||||||
|
authorization: boolean, };
|
||||||
|
|
||||||
|
export type SavedFlow = { requestId: string, name: string, };
|
||||||
|
|
||||||
export type ServiceProxy = {
|
export type ServiceProxy = {
|
||||||
/**
|
/**
|
||||||
@@ -131,6 +272,10 @@ export type ServiceProxy = {
|
|||||||
*/
|
*/
|
||||||
name: string, http: ProxySetting, https: ProxySetting, };
|
name: string, http: ProxySetting, https: ProxySetting, };
|
||||||
|
|
||||||
|
export type SetRecordingRequest = { enabled: boolean, };
|
||||||
|
|
||||||
|
export type SetSystemProxyAtLaunchRequest = { enabled: boolean, };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How a change gets administrator rights.
|
* How a change gets administrator rights.
|
||||||
*/
|
*/
|
||||||
@@ -153,3 +298,7 @@ port: number, services: Array<ServiceProxy>, backend: SystemProxyBackend,
|
|||||||
supported: boolean, };
|
supported: boolean, };
|
||||||
|
|
||||||
export type SystemProxyStatusRequest = Record<string, never>;
|
export type SystemProxyStatusRequest = Record<string, never>;
|
||||||
|
|
||||||
|
export type TakeOverRequest = Record<string, never>;
|
||||||
|
|
||||||
|
export type WorkspaceSummary = { id: string, name: string, folders: Array<FolderSummary>, };
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- The app that opened the flow's client connection, where the platform can tell.
|
||||||
|
ALTER TABLE flows ADD COLUMN client_pid INTEGER;
|
||||||
|
ALTER TABLE flows ADD COLUMN client_name TEXT;
|
||||||
|
ALTER TABLE flows ADD COLUMN client_bundle_id TEXT;
|
||||||
|
ALTER TABLE flows ADD COLUMN client_path TEXT;
|
||||||
|
|
||||||
|
-- Flows list and page newest first, by start and then id.
|
||||||
|
DROP INDEX idx_flows_started_at;
|
||||||
|
CREATE INDEX idx_flows_started_at_id ON flows (started_at, id);
|
||||||
@@ -5,8 +5,8 @@ use ts_rs::TS;
|
|||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
pub enum GlobalAction {
|
pub enum GlobalAction {
|
||||||
ProxyStart,
|
/// Pauses or resumes storing flows.
|
||||||
ProxyStop,
|
RecordingToggle,
|
||||||
FlowsClear,
|
FlowsClear,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,17 +33,11 @@ fn default_hotkey(mac: &str, other: &str) -> Option<String> {
|
|||||||
pub fn all_global_actions() -> Vec<(ActionInvocation, ActionMetadata)> {
|
pub fn all_global_actions() -> Vec<(ActionInvocation, ActionMetadata)> {
|
||||||
vec![
|
vec![
|
||||||
(
|
(
|
||||||
ActionInvocation::Global { action: GlobalAction::ProxyStart },
|
ActionInvocation::Global { action: GlobalAction::RecordingToggle },
|
||||||
ActionMetadata {
|
ActionMetadata {
|
||||||
label: "Start Proxy".into(),
|
label: "Pause or Resume Recording".into(),
|
||||||
default_hotkey: default_hotkey("Meta+Shift+P", "Ctrl+Shift+P"),
|
// Not Cmd+Shift+R, which reloads the window in development builds.
|
||||||
},
|
default_hotkey: default_hotkey("Meta+R", "Ctrl+Shift+R"),
|
||||||
),
|
|
||||||
(
|
|
||||||
ActionInvocation::Global { action: GlobalAction::ProxyStop },
|
|
||||||
ActionMetadata {
|
|
||||||
label: "Stop Proxy".into(),
|
|
||||||
default_hotkey: default_hotkey("Meta+Shift+S", "Ctrl+Shift+S"),
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,8 @@
|
|||||||
use crate::bodies::{self, DecodedBody};
|
use crate::bodies::{self, DecodedBody};
|
||||||
use crate::ca::CaStatus;
|
use crate::ca::CaStatus;
|
||||||
use crate::control::{
|
use crate::control::{
|
||||||
self, ControlInfo, ControlStatus, ErrorBody, StartRequest, SystemProxyRequest, TRUNCATED_HEADER,
|
self, ControlInfo, ControlStatus, ErrorBody, RecordingRequest, StartRequest,
|
||||||
|
SystemProxyRequest, TRUNCATED_HEADER,
|
||||||
};
|
};
|
||||||
use crate::db::ProxyQueryManager;
|
use crate::db::ProxyQueryManager;
|
||||||
use crate::flows::{self, FlowDetail, FlowFilter};
|
use crate::flows::{self, FlowDetail, FlowFilter};
|
||||||
@@ -127,6 +128,11 @@ impl ControlClient {
|
|||||||
self.post("/ca/trust", &serde_json::json!({})).await
|
self.post("/ca/trust", &serde_json::json!({})).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resumes or pauses storing flows in the owner's engine.
|
||||||
|
pub async fn set_recording(&self, enabled: bool) -> Result<ControlStatus, String> {
|
||||||
|
self.post("/recording", &RecordingRequest { enabled }).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn system_proxy(&self) -> Result<SystemProxyStatus, String> {
|
pub async fn system_proxy(&self) -> Result<SystemProxyStatus, String> {
|
||||||
self.get("/system-proxy").await
|
self.get("/system-proxy").await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ pub struct ControlStatus {
|
|||||||
pub proxy_port: Option<u16>,
|
pub proxy_port: Option<u16>,
|
||||||
/// The port the engine starts on.
|
/// The port the engine starts on.
|
||||||
pub configured_port: u16,
|
pub configured_port: u16,
|
||||||
|
/// Flows are stored. Paused, traffic still passes through, unrecorded.
|
||||||
|
pub recording: bool,
|
||||||
pub flow_count: usize,
|
pub flow_count: usize,
|
||||||
pub ca: CaStatus,
|
pub ca: CaStatus,
|
||||||
/// `None` when it could not be read.
|
/// `None` when it could not be read.
|
||||||
@@ -222,6 +224,11 @@ pub struct SystemProxyRequest {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RecordingRequest {
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ClearResponse {
|
pub struct ClearResponse {
|
||||||
/// How many flows were deleted.
|
/// How many flows were deleted.
|
||||||
@@ -344,11 +351,14 @@ impl ControlServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn already_owned(owner: &ControlInfo) -> String {
|
pub(crate) fn already_owned(owner: &ControlInfo) -> String {
|
||||||
let who = match owner.owner {
|
format!("The proxy is already running in {} (pid {})", owner_name(owner.owner), owner.pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn owner_name(owner: Owner) -> &'static str {
|
||||||
|
match owner {
|
||||||
Owner::App => "Yaak Proxy",
|
Owner::App => "Yaak Proxy",
|
||||||
Owner::Cli => "the yaak CLI",
|
Owner::Cli => "the yaak CLI",
|
||||||
};
|
}
|
||||||
format!("The proxy is already running in {who} (pid {})", owner.pid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for ControlServer {
|
impl Drop for ControlServer {
|
||||||
@@ -472,6 +482,11 @@ async fn route(state: &ServerState, req: Request<Incoming>) -> Result<ControlRes
|
|||||||
let _ = state.stop_tx.send(());
|
let _ = state.stop_tx.send(());
|
||||||
json(&status(ctx).await?)
|
json(&status(ctx).await?)
|
||||||
}
|
}
|
||||||
|
(&Method::POST, ["recording"]) => {
|
||||||
|
let body: RecordingRequest = read_json(req).await?;
|
||||||
|
ctx.set_recording(body.enabled);
|
||||||
|
json(&status(ctx).await?)
|
||||||
|
}
|
||||||
(&Method::GET, ["flows"]) => {
|
(&Method::GET, ["flows"]) => {
|
||||||
let filter = FlowFilter::from_query(&query).map_err(bad_request)?;
|
let filter = FlowFilter::from_query(&query).map_err(bad_request)?;
|
||||||
json(&flows::list(&ctx.db, &filter)?)
|
json(&flows::list(&ctx.db, &filter)?)
|
||||||
@@ -509,6 +524,7 @@ async fn route(state: &ServerState, req: Request<Incoming>) -> Result<ControlRes
|
|||||||
let ca = CaStore::load_or_create(&ctx.ca_dir())
|
let ca = CaStore::load_or_create(&ctx.ca_dir())
|
||||||
.map_err(|e| format!("Failed to load proxy CA: {e}"))?;
|
.map_err(|e| format!("Failed to load proxy CA: {e}"))?;
|
||||||
ca::trust(ca.cert_path()).await.map_err(bad_request)?;
|
ca::trust(ca.cert_path()).await.map_err(bad_request)?;
|
||||||
|
ctx.forget_passthrough();
|
||||||
json(&ca::status(ca.cert_path()).await?)
|
json(&ca::status(ca.cert_path()).await?)
|
||||||
}
|
}
|
||||||
(&Method::GET, ["system-proxy"]) => {
|
(&Method::GET, ["system-proxy"]) => {
|
||||||
@@ -541,6 +557,7 @@ async fn status(ctx: &ProxyCtx) -> Result<ControlStatus, ApiError> {
|
|||||||
state: if proxy_port.is_some() { ProxyState::Running } else { ProxyState::Stopped },
|
state: if proxy_port.is_some() { ProxyState::Running } else { ProxyState::Stopped },
|
||||||
proxy_port,
|
proxy_port,
|
||||||
configured_port: ctx.settings()?.port,
|
configured_port: ctx.settings()?.port,
|
||||||
|
recording: ctx.recording(),
|
||||||
flow_count: flows::count(&ctx.db)?,
|
flow_count: flows::count(&ctx.db)?,
|
||||||
ca,
|
ca,
|
||||||
system_proxy,
|
system_proxy,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use crate::models::{Flow, find_header};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use ts_rs::TS;
|
||||||
use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter};
|
use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter};
|
||||||
|
|
||||||
// The client's body types (`apps/yaak-client/lib/model_util.ts`).
|
// The client's body types (`apps/yaak-client/lib/model_util.ts`).
|
||||||
@@ -28,16 +29,18 @@ const HOP_BY_HOP: &[&str] = &[
|
|||||||
/// Yaak works these out itself when it sends the request.
|
/// Yaak works these out itself when it sends the request.
|
||||||
const AUTOMATIC: &[&str] = &["host", "content-length", "accept-encoding"];
|
const AUTOMATIC: &[&str] = &["host", "content-length", "accept-encoding"];
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
pub struct DroppedHeader {
|
pub struct DroppedHeader {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub value: String,
|
pub value: String,
|
||||||
pub reason: DropReason,
|
pub reason: DropReason,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
pub enum DropReason {
|
pub enum DropReason {
|
||||||
/// Only meant for the hop between the app and the proxy.
|
/// Only meant for the hop between the app and the proxy.
|
||||||
HopByHop,
|
HopByHop,
|
||||||
@@ -72,8 +75,8 @@ pub fn flow_to_request(flow: &Flow, body: &DecodedBody) -> Conversion {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No name, as in Yaak, which then shows the URL.
|
||||||
let request = HttpRequest {
|
let request = HttpRequest {
|
||||||
name: default_name(&url),
|
|
||||||
method: flow.method.clone(),
|
method: flow.method.clone(),
|
||||||
url,
|
url,
|
||||||
url_parameters,
|
url_parameters,
|
||||||
@@ -228,16 +231,6 @@ fn graphql_body(flow: &Flow, text: &str) -> Option<BTreeMap<String, Value>> {
|
|||||||
Some(fields)
|
Some(fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The path, or the host for a request to the root.
|
|
||||||
fn default_name(url: &str) -> String {
|
|
||||||
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
|
|
||||||
match rest.split_once('/') {
|
|
||||||
Some((_, path)) if !path.is_empty() => format!("/{path}"),
|
|
||||||
Some((host, _)) => host.to_string(),
|
|
||||||
None => rest.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -287,7 +280,7 @@ mod tests {
|
|||||||
let r = &c.request;
|
let r = &c.request;
|
||||||
assert_eq!(r.method, "GET");
|
assert_eq!(r.method, "GET");
|
||||||
assert_eq!(r.url, "https://api.github.com/search/issues");
|
assert_eq!(r.url, "https://api.github.com/search/issues");
|
||||||
assert_eq!(r.name, "/search/issues");
|
assert_eq!(r.name, "");
|
||||||
let params: Vec<(&str, &str)> =
|
let params: Vec<(&str, &str)> =
|
||||||
r.url_parameters.iter().map(|p| (p.name.as_str(), p.value.as_str())).collect();
|
r.url_parameters.iter().map(|p| (p.name.as_str(), p.value.as_str())).collect();
|
||||||
assert_eq!(params, vec![("q", "is:open label:bug"), ("per_page", "5")]);
|
assert_eq!(params, vec![("q", "is:open label:bug"), ("per_page", "5")]);
|
||||||
@@ -391,7 +384,7 @@ mod tests {
|
|||||||
{ "enabled": true, "name": "note", "value": "hello world!" },
|
{ "enabled": true, "name": "note", "value": "hello world!" },
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
assert_eq!(c.request.name, "/login");
|
assert_eq!(c.request.name, "", "unnamed, so Yaak shows its URL");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -412,7 +405,7 @@ mod tests {
|
|||||||
let c = flow_to_request(&f, &body("hello"));
|
let c = flow_to_request(&f, &body("hello"));
|
||||||
assert_eq!(c.request.body_type.as_deref(), Some("other"));
|
assert_eq!(c.request.body_type.as_deref(), Some("other"));
|
||||||
assert_eq!(c.request.body["text"], "hello");
|
assert_eq!(c.request.body["text"], "hello");
|
||||||
assert_eq!(c.request.name, "x.test");
|
assert_eq!(c.request.name, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use include_dir::{Dir, include_dir};
|
use include_dir::{Dir, include_dir};
|
||||||
use r2d2::Pool;
|
use r2d2::Pool;
|
||||||
use r2d2_sqlite::SqliteConnectionManager;
|
use r2d2_sqlite::SqliteConnectionManager;
|
||||||
|
use rusqlite::functions::FunctionFlags;
|
||||||
use rusqlite::{OpenFlags, Transaction, TransactionBehavior};
|
use rusqlite::{OpenFlags, Transaction, TransactionBehavior};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -76,10 +77,23 @@ pub fn open_blobs_read_only(path: &Path) -> Result<BlobManager> {
|
|||||||
Ok(BlobManager::new(readers.clone(), readers))
|
Ok(BlobManager::new(readers.clone(), readers))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `sidebar_path(url)`, the app sidebar's place for a URL, for filtering by its nodes.
|
||||||
|
fn add_functions(conn: &mut rusqlite::Connection) -> rusqlite::Result<()> {
|
||||||
|
conn.create_scalar_function(
|
||||||
|
"sidebar_path",
|
||||||
|
1,
|
||||||
|
FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC,
|
||||||
|
|ctx| Ok(crate::flows::sidebar_path(&ctx.get::<String>(0)?)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn read_only_pool(path: &Path) -> Result<SqlitePool> {
|
fn read_only_pool(path: &Path) -> Result<SqlitePool> {
|
||||||
let manager = SqliteConnectionManager::file(path)
|
let manager = SqliteConnectionManager::file(path)
|
||||||
.with_flags(OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX)
|
.with_flags(OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX)
|
||||||
.with_init(|conn| conn.busy_timeout(Duration::from_millis(5000)));
|
.with_init(|conn| {
|
||||||
|
conn.busy_timeout(Duration::from_millis(5000))?;
|
||||||
|
add_functions(conn)
|
||||||
|
});
|
||||||
Pool::builder()
|
Pool::builder()
|
||||||
.max_size(2)
|
.max_size(2)
|
||||||
.connection_timeout(Duration::from_secs(10))
|
.connection_timeout(Duration::from_secs(10))
|
||||||
@@ -91,7 +105,8 @@ fn file_pool(path: &Path, max_size: u32) -> Result<SqlitePool> {
|
|||||||
let manager = SqliteConnectionManager::file(path).with_init(|conn| {
|
let manager = SqliteConnectionManager::file(path).with_init(|conn| {
|
||||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||||
conn.busy_timeout(Duration::from_millis(5000))
|
conn.busy_timeout(Duration::from_millis(5000))?;
|
||||||
|
add_functions(conn)
|
||||||
});
|
});
|
||||||
Pool::builder()
|
Pool::builder()
|
||||||
.max_size(max_size)
|
.max_size(max_size)
|
||||||
@@ -117,10 +132,14 @@ mod tests {
|
|||||||
let path = dir.path().join("proxy.db");
|
let path = dir.path().join("proxy.db");
|
||||||
drop(ProxyQueryManager::new(&path).unwrap());
|
drop(ProxyQueryManager::new(&path).unwrap());
|
||||||
|
|
||||||
// As a build from before the saved request column left it.
|
// As a build from before the saved request and client columns left it.
|
||||||
let conn = Connection::open(&path).unwrap();
|
let conn = Connection::open(&path).unwrap();
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"ALTER TABLE flows DROP COLUMN saved_request_id;
|
"ALTER TABLE flows DROP COLUMN saved_request_id;
|
||||||
|
ALTER TABLE flows DROP COLUMN client_pid;
|
||||||
|
ALTER TABLE flows DROP COLUMN client_name;
|
||||||
|
ALTER TABLE flows DROP COLUMN client_bundle_id;
|
||||||
|
ALTER TABLE flows DROP COLUMN client_path;
|
||||||
INSERT INTO flows (id, url, method) VALUES ('fl_old', 'https://example.com', 'GET');",
|
INSERT INTO flows (id, url, method) VALUES ('fl_old', 'https://example.com', 'GET');",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -130,6 +149,7 @@ mod tests {
|
|||||||
let flows = db.with_conn(|c| c.find_all::<Flow>()).unwrap();
|
let flows = db.with_conn(|c| c.find_all::<Flow>()).unwrap();
|
||||||
assert_eq!(flows.len(), 1);
|
assert_eq!(flows.len(), 1);
|
||||||
assert_eq!((flows[0].id.as_str(), flows[0].saved_request_id.as_deref()), ("fl_old", None));
|
assert_eq!((flows[0].id.as_str(), flows[0].saved_request_id.as_deref()), ("fl_old", None));
|
||||||
|
assert_eq!((flows[0].client_pid, flows[0].client_name.as_deref()), (None, None));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -169,6 +189,22 @@ mod tests {
|
|||||||
|
|
||||||
let flow_columns = columns(&conn, "flows");
|
let flow_columns = columns(&conn, "flows");
|
||||||
assert!(flow_columns.contains(&"response_truncated".to_string()));
|
assert!(flow_columns.contains(&"response_truncated".to_string()));
|
||||||
|
for column in [
|
||||||
|
"client_pid",
|
||||||
|
"client_name",
|
||||||
|
"client_bundle_id",
|
||||||
|
"client_path",
|
||||||
|
] {
|
||||||
|
assert!(flow_columns.contains(&column.to_string()), "{column} in {flow_columns:?}");
|
||||||
|
}
|
||||||
|
let indexes: Vec<String> = conn
|
||||||
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'flows' AND sql IS NOT NULL")
|
||||||
|
.unwrap()
|
||||||
|
.query_map([], |r| r.get(0))
|
||||||
|
.unwrap()
|
||||||
|
.map(|i| i.unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(indexes, vec!["idx_flows_started_at_id"]);
|
||||||
assert!(!flow_columns.iter().any(|c| c.contains("body")), "{flow_columns:?}");
|
assert!(!flow_columns.iter().any(|c| c.contains("body")), "{flow_columns:?}");
|
||||||
|
|
||||||
let journal_mode: String = conn.query_row("PRAGMA journal_mode", [], |r| r.get(0)).unwrap();
|
let journal_mode: String = conn.query_row("PRAGMA journal_mode", [], |r| r.get(0)).unwrap();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use crate::db::ProxyQueryManager;
|
|||||||
use crate::models::{Flow, FlowBody, FlowIden};
|
use crate::models::{Flow, FlowBody, FlowIden};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
use rusqlite::types::Value;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
use yaak_database::UpsertModelInfo;
|
use yaak_database::UpsertModelInfo;
|
||||||
@@ -13,9 +14,21 @@ use yaak_models::blob_manager::BlobManager;
|
|||||||
#[serde(default, rename_all = "camelCase")]
|
#[serde(default, rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
pub struct FlowFilter {
|
pub struct FlowFilter {
|
||||||
|
/// Search terms, any case, each of which must match the URL (host, path or query), the
|
||||||
|
/// method, the status (`404`, `40`, `4xx`) or the content type: `stripe post 4xx`.
|
||||||
|
#[ts(optional)]
|
||||||
|
pub q: Option<String>,
|
||||||
/// Part of the host, any case: `stripe` matches `api.stripe.com`.
|
/// Part of the host, any case: `stripe` matches `api.stripe.com`.
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub host: Option<String>,
|
pub host: Option<String>,
|
||||||
|
/// Only flows under one of these hosts or paths, as the app's sidebar names them:
|
||||||
|
/// `api.stripe.com` or `api.stripe.com/v1/charges`, with any port after the host.
|
||||||
|
#[ts(optional)]
|
||||||
|
pub under: Option<Vec<String>>,
|
||||||
|
/// Only flows from one of these apps, by bundle id, or by path for a process outside an
|
||||||
|
/// app bundle.
|
||||||
|
#[ts(optional)]
|
||||||
|
pub app: Option<Vec<String>>,
|
||||||
/// The method, any case.
|
/// The method, any case.
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub method: Option<String>,
|
pub method: Option<String>,
|
||||||
@@ -36,14 +49,17 @@ pub struct FlowFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FlowFilter {
|
impl FlowFilter {
|
||||||
/// The filter from a query string's `host`, `method`, `status`, `limit`, `after` and
|
/// The filter from a query string's `q`, `host`, `under`, `app`, `method`, `status`,
|
||||||
/// `before`.
|
/// `limit`, `after` and `before`. `under` and `app` may repeat.
|
||||||
pub fn from_query(query: &str) -> Result<Self, String> {
|
pub fn from_query(query: &str) -> Result<Self, String> {
|
||||||
let mut filter = Self::default();
|
let mut filter = Self::default();
|
||||||
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
|
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
|
||||||
let value = Some(value.into_owned()).filter(|v| !v.is_empty());
|
let value = Some(value.into_owned()).filter(|v| !v.is_empty());
|
||||||
match key.as_ref() {
|
match key.as_ref() {
|
||||||
|
"q" => filter.q = value,
|
||||||
"host" => filter.host = value,
|
"host" => filter.host = value,
|
||||||
|
"under" => filter.under.get_or_insert_default().extend(value),
|
||||||
|
"app" => filter.app.get_or_insert_default().extend(value),
|
||||||
"method" => filter.method = value,
|
"method" => filter.method = value,
|
||||||
"status" => filter.status = value,
|
"status" => filter.status = value,
|
||||||
"after" => filter.after = value,
|
"after" => filter.after = value,
|
||||||
@@ -62,8 +78,17 @@ impl FlowFilter {
|
|||||||
pub fn to_query(&self) -> String {
|
pub fn to_query(&self) -> String {
|
||||||
let mut query = url::form_urlencoded::Serializer::new(String::new());
|
let mut query = url::form_urlencoded::Serializer::new(String::new());
|
||||||
let limit = self.limit.map(|l| l.to_string());
|
let limit = self.limit.map(|l| l.to_string());
|
||||||
|
for (key, value) in [("q", &self.q), ("host", &self.host)] {
|
||||||
|
if let Some(value) = value {
|
||||||
|
query.append_pair(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (key, values) in [("under", &self.under), ("app", &self.app)] {
|
||||||
|
for value in values.iter().flatten() {
|
||||||
|
query.append_pair(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
for (key, value) in [
|
for (key, value) in [
|
||||||
("host", &self.host),
|
|
||||||
("method", &self.method),
|
("method", &self.method),
|
||||||
("status", &self.status),
|
("status", &self.status),
|
||||||
("limit", &limit),
|
("limit", &limit),
|
||||||
@@ -77,56 +102,175 @@ impl FlowFilter {
|
|||||||
query.finish()
|
query.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn matches(&self, flow: &Flow) -> bool {
|
/// Narrows more than `after`, `before` and `limit` do, so it covers fewer than all flows.
|
||||||
if let Some(host) = &self.host
|
pub fn narrows(&self) -> bool {
|
||||||
&& !host_of(&flow.url).to_ascii_lowercase().contains(&host.to_ascii_lowercase())
|
let given = |v: &Option<String>| v.as_deref().is_some_and(|v| !v.trim().is_empty());
|
||||||
{
|
given(&self.q)
|
||||||
return false;
|
|| given(&self.host)
|
||||||
|
|| given(&self.method)
|
||||||
|
|| given(&self.status)
|
||||||
|
|| self.under.is_some()
|
||||||
|
|| self.app.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SQL condition for every field but `after`, `before` and `limit`, and its parameters.
|
||||||
|
fn condition(&self) -> (String, Vec<Value>) {
|
||||||
|
let mut sql = String::from("1");
|
||||||
|
let mut params: Vec<Value> = Vec::new();
|
||||||
|
for term in self.q.iter().flat_map(|q| q.split_whitespace()) {
|
||||||
|
let term = term.to_lowercase();
|
||||||
|
sql.push_str(
|
||||||
|
" AND (instr(lower(url), ?) > 0 OR lower(method) = ? \
|
||||||
|
OR instr(lower(content_type), ?) > 0",
|
||||||
|
);
|
||||||
|
params.extend([
|
||||||
|
term.clone().into(),
|
||||||
|
term.clone().into(),
|
||||||
|
term.clone().into(),
|
||||||
|
]);
|
||||||
|
if let Some(status) = status_condition(&term, &mut params) {
|
||||||
|
sql.push_str(" OR ");
|
||||||
|
sql.push_str(&status);
|
||||||
|
}
|
||||||
|
sql.push(')');
|
||||||
}
|
}
|
||||||
if let Some(method) = &self.method
|
if let Some(host) = self.host.as_deref().filter(|h| !h.is_empty()) {
|
||||||
&& !flow.method.eq_ignore_ascii_case(method)
|
sql.push_str(&format!(" AND instr(lower({HOST_SQL}), ?) > 0"));
|
||||||
{
|
params.push(host.to_lowercase().into());
|
||||||
return false;
|
}
|
||||||
|
if let Some(under) = &self.under {
|
||||||
|
// The node itself, or anything below it, by the sidebar's rules.
|
||||||
|
let mut any = Vec::new();
|
||||||
|
for node in under {
|
||||||
|
let node = normalize_node(node);
|
||||||
|
any.push("(sidebar_path(url) = ? OR substr(sidebar_path(url), 1, ?) = ?)");
|
||||||
|
let below = format!("{node}/");
|
||||||
|
params.extend([
|
||||||
|
node.into(),
|
||||||
|
(below.chars().count() as i64).into(),
|
||||||
|
below.into(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
let any = if any.is_empty() { "0".to_string() } else { any.join(" OR ") };
|
||||||
|
sql.push_str(&format!(" AND ({any})"));
|
||||||
|
}
|
||||||
|
if let Some(apps) = &self.app {
|
||||||
|
let marks = vec!["?"; apps.len()].join(", ");
|
||||||
|
sql.push_str(&format!(" AND coalesce(client_bundle_id, client_path) IN ({marks})"));
|
||||||
|
params.extend(apps.iter().map(|a| a.clone().into()));
|
||||||
|
}
|
||||||
|
if let Some(method) = self.method.as_deref().filter(|m| !m.is_empty()) {
|
||||||
|
sql.push_str(" AND lower(method) = ?");
|
||||||
|
params.push(method.to_lowercase().into());
|
||||||
}
|
}
|
||||||
match self.status.as_deref().map(str::trim) {
|
match self.status.as_deref().map(str::trim) {
|
||||||
None | Some("") => true,
|
None | Some("") => {}
|
||||||
Some(s) if s.eq_ignore_ascii_case("error") => flow.error.is_some(),
|
Some(s) if s.eq_ignore_ascii_case("error") => sql.push_str(" AND error IS NOT NULL"),
|
||||||
Some(s) => match (flow.status, s.to_ascii_lowercase().strip_suffix("xx")) {
|
// A whole status or a class, not the start of one as a search term may be.
|
||||||
(Some(status), Some(class)) => class.parse() == Ok(status / 100),
|
Some(s) if s.len() == 3 => match status_condition(&s.to_ascii_lowercase(), &mut params)
|
||||||
(Some(status), None) => s.parse() == Ok(status),
|
{
|
||||||
(None, _) => false,
|
Some(status) => {
|
||||||
|
sql.push_str(" AND ");
|
||||||
|
sql.push_str(&status);
|
||||||
|
}
|
||||||
|
None => sql.push_str(" AND 0"),
|
||||||
},
|
},
|
||||||
|
Some(_) => sql.push_str(" AND 0"),
|
||||||
}
|
}
|
||||||
|
(sql, params)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `host[:port]` of a captured URL.
|
/// The host and port of `url`, in SQL: what follows `://` up to the first `/`, `?` or `#`.
|
||||||
fn host_of(url: &str) -> &str {
|
const HOST_SQL: &str = "(SELECT CASE WHEN instr(r, '/') > 0 THEN substr(r, 1, instr(r, '/') - 1) \
|
||||||
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
|
ELSE r END FROM (SELECT replace(replace(CASE WHEN instr(url, '://') > 0 \
|
||||||
rest.split(['/', '?', '#']).next().unwrap_or_default()
|
THEN substr(url, instr(url, '://') + 3) ELSE url END, '?', '/'), '#', '/') AS r))";
|
||||||
|
|
||||||
|
/// Where the app's sidebar places a URL: its host, lowercased and without its scheme's default
|
||||||
|
/// port, then its path's non-empty segments, joined by `/`, as in `api.stripe.com/v1/charges`.
|
||||||
|
/// A URL without a scheme is a host of its own. `sidebarPath` in the app's `lib/search.ts`
|
||||||
|
/// follows the same rules, and the sidebar's nodes are built from it.
|
||||||
|
pub fn sidebar_path(url: &str) -> String {
|
||||||
|
let Some((scheme, rest)) = url.split_once("://") else {
|
||||||
|
return url.to_string();
|
||||||
|
};
|
||||||
|
let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
|
||||||
|
let (authority, rest) = rest.split_at(end);
|
||||||
|
let mut host = authority.to_ascii_lowercase();
|
||||||
|
let default_port = match scheme.to_ascii_lowercase().as_str() {
|
||||||
|
"http" | "ws" => Some(":80"),
|
||||||
|
"https" | "wss" => Some(":443"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(port) = default_port
|
||||||
|
&& host.ends_with(port)
|
||||||
|
{
|
||||||
|
host.truncate(host.len() - port.len());
|
||||||
|
}
|
||||||
|
let path = rest.split(['?', '#']).next().unwrap_or_default();
|
||||||
|
let mut key = host;
|
||||||
|
for segment in path.split('/').filter(|s| !s.is_empty()) {
|
||||||
|
key.push('/');
|
||||||
|
key.push_str(segment);
|
||||||
|
}
|
||||||
|
key
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The flows `filter` covers, newest first: by start, then by id. Reads only as far into the
|
/// A sidebar node as `sidebar_path` writes it, its host lowercased.
|
||||||
/// table as the limit needs, so a page costs the same however many flows there are.
|
fn normalize_node(node: &str) -> String {
|
||||||
|
match node.split_once('/') {
|
||||||
|
Some((host, path)) => format!("{}/{path}", host.to_ascii_lowercase()),
|
||||||
|
None => node.to_ascii_lowercase(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SQL condition for a status term: `404` exactly, `40` as the start of one, or a class
|
||||||
|
/// such as `4xx`. None when the term is not one.
|
||||||
|
fn status_condition(term: &str, params: &mut Vec<Value>) -> Option<String> {
|
||||||
|
if let Some(class) = term.strip_suffix("xx")
|
||||||
|
&& let Ok(class @ 1..=9) = class.parse::<i64>()
|
||||||
|
{
|
||||||
|
params.push(class.into());
|
||||||
|
return Some("status / 100 = ?".into());
|
||||||
|
}
|
||||||
|
if term.is_empty() || term.len() > 3 || !term.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if term.len() == 3 {
|
||||||
|
params.push(term.parse::<i64>().ok()?.into());
|
||||||
|
return Some("status = ?".into());
|
||||||
|
}
|
||||||
|
params.push(format!("{term}%").into());
|
||||||
|
Some("CAST(status AS TEXT) LIKE ?".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The flows `filter` covers, newest first: by start, then by id. Reads the table in that order
|
||||||
|
/// through its index and stops at the limit, so a page of a common match costs the same however
|
||||||
|
/// many flows there are.
|
||||||
pub fn list(db: &ProxyQueryManager, filter: &FlowFilter) -> Result<Vec<Flow>, String> {
|
pub fn list(db: &ProxyQueryManager, filter: &FlowFilter) -> Result<Vec<Flow>, String> {
|
||||||
db.with_conn(|c| list_in(c.conn().resolve(), filter))
|
db.with_conn(|c| list_in(c.conn().resolve(), filter))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`list`] and the count of every flow, read from one snapshot of the database, so the count
|
/// [`list`] and the count of every flow, read from one snapshot of the database, so the count
|
||||||
/// takes in exactly the flows written before the page was read.
|
/// takes in exactly the flows written before the page was read. With a filter that narrows,
|
||||||
pub fn page(db: &ProxyQueryManager, filter: &FlowFilter) -> Result<(Vec<Flow>, usize), String> {
|
/// also the count of the flows it covers, whatever the limit.
|
||||||
|
pub fn page(
|
||||||
|
db: &ProxyQueryManager,
|
||||||
|
filter: &FlowFilter,
|
||||||
|
) -> Result<(Vec<Flow>, usize, Option<usize>), String> {
|
||||||
db.with_conn(|c| {
|
db.with_conn(|c| {
|
||||||
let tx = c.conn().resolve().unchecked_transaction().map_err(|e| e.to_string())?;
|
let tx = c.conn().resolve().unchecked_transaction().map_err(|e| e.to_string())?;
|
||||||
let flows = list_in(&tx, filter)?;
|
let flows = list_in(&tx, filter)?;
|
||||||
let total = count_in(&tx)?;
|
let total = count_in(&tx)?;
|
||||||
|
let matching = if filter.narrows() { Some(count_matching(&tx, filter)?) } else { None };
|
||||||
tx.commit().map_err(|e| e.to_string())?;
|
tx.commit().map_err(|e| e.to_string())?;
|
||||||
Ok((flows, total))
|
Ok((flows, total, matching))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_in(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result<Vec<Flow>, String> {
|
fn list_in(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result<Vec<Flow>, String> {
|
||||||
let mut sql = String::from("SELECT * FROM flows WHERE 1");
|
let (condition, mut params) = filter.condition();
|
||||||
let mut params: Vec<rusqlite::types::Value> = Vec::new();
|
let mut sql = format!("SELECT * FROM flows WHERE {condition}");
|
||||||
if let Some(id) = &filter.after {
|
if let Some(id) = &filter.after {
|
||||||
// By rowid, which only grows as flows are written.
|
// By rowid, which only grows as flows are written.
|
||||||
let rowid: i64 = conn
|
let rowid: i64 = conn
|
||||||
@@ -136,7 +280,7 @@ fn list_in(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result<Vec<Flow>
|
|||||||
params.push(rowid.into());
|
params.push(rowid.into());
|
||||||
}
|
}
|
||||||
if let Some(id) = &filter.before {
|
if let Some(id) = &filter.before {
|
||||||
let started_at: rusqlite::types::Value = conn
|
let started_at: Value = conn
|
||||||
.query_row("SELECT started_at FROM flows WHERE id = ?", [id], |r| r.get(0))
|
.query_row("SELECT started_at FROM flows WHERE id = ?", [id], |r| r.get(0))
|
||||||
.map_err(|_| format!("No flow {id}"))?;
|
.map_err(|_| format!("No flow {id}"))?;
|
||||||
sql.push_str(" AND (started_at, id) < (?, ?)");
|
sql.push_str(" AND (started_at, id) < (?, ?)");
|
||||||
@@ -144,21 +288,26 @@ fn list_in(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result<Vec<Flow>
|
|||||||
params.push(id.clone().into());
|
params.push(id.clone().into());
|
||||||
}
|
}
|
||||||
sql.push_str(" ORDER BY started_at DESC, id DESC");
|
sql.push_str(" ORDER BY started_at DESC, id DESC");
|
||||||
|
if let Some(limit) = filter.limit {
|
||||||
|
sql.push_str(" LIMIT ?");
|
||||||
|
params.push((limit.min(i64::MAX as usize) as i64).into());
|
||||||
|
}
|
||||||
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
|
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(rusqlite::params_from_iter(params), Flow::from_row)
|
.query_map(rusqlite::params_from_iter(params), Flow::from_row)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let mut flows = Vec::new();
|
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||||
for flow in rows {
|
}
|
||||||
if flows.len() >= filter.limit.unwrap_or(usize::MAX) {
|
|
||||||
break;
|
fn count_matching(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result<usize, String> {
|
||||||
}
|
let (condition, params) = filter.condition();
|
||||||
let flow = flow.map_err(|e| e.to_string())?;
|
conn.query_row(
|
||||||
if filter.matches(&flow) {
|
&format!("SELECT COUNT(*) FROM flows WHERE {condition}"),
|
||||||
flows.push(flow);
|
rusqlite::params_from_iter(params),
|
||||||
}
|
|r| r.get::<_, i64>(0),
|
||||||
}
|
)
|
||||||
Ok(flows)
|
.map(|n| n as usize)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes every flow and their bodies, in a transaction in each database. The flows' deletion
|
/// Deletes every flow and their bodies, in a transaction in each database. The flows' deletion
|
||||||
@@ -288,26 +437,218 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A database of `flows`, started a second apart in the order given, and a function that
|
||||||
|
/// lists the urls a filter covers, newest first.
|
||||||
|
fn db_of(flows: Vec<Flow>) -> (tempfile::TempDir, ProxyQueryManager) {
|
||||||
|
use yaak_database::UpdateSource;
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap();
|
||||||
|
for (i, flow) in flows.into_iter().enumerate() {
|
||||||
|
let started_at = chrono::DateTime::from_timestamp(i as i64, 0).unwrap().naive_utc();
|
||||||
|
let flow = Flow { id: format!("fl_{i:03}"), started_at, ..flow };
|
||||||
|
db.with_tx(|tx| tx.upsert(&flow, &UpdateSource::Background)).unwrap();
|
||||||
|
}
|
||||||
|
(dir, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn urls(db: &ProxyQueryManager, filter: FlowFilter) -> Vec<String> {
|
||||||
|
list(db, &filter).unwrap().into_iter().map(|f| f.url).collect()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn filters() {
|
fn filters() {
|
||||||
let ok = flow("https://api.stripe.com:8443/v1/charges?x=1", "POST", Some(200), None);
|
let (_dir, db) = db_of(vec![
|
||||||
let missing = flow("http://example.com/missing", "GET", Some(404), None);
|
flow("https://api.stripe.com:8443/v1/charges?x=1", "POST", Some(200), None),
|
||||||
let failed = flow("https://down.example.com/", "GET", None, Some("refused"));
|
flow("http://example.com/missing", "GET", Some(404), None),
|
||||||
|
flow("https://down.example.com/", "GET", None, Some("refused")),
|
||||||
let by = |filter: FlowFilter| {
|
]);
|
||||||
[&ok, &missing, &failed].iter().filter(|f| filter.matches(f)).count()
|
let by = |filter: FlowFilter| urls(&db, filter).len();
|
||||||
};
|
|
||||||
assert_eq!(by(FlowFilter::default()), 3);
|
assert_eq!(by(FlowFilter::default()), 3);
|
||||||
assert_eq!(by(FlowFilter { host: Some("STRIPE".into()), ..Default::default() }), 1);
|
assert_eq!(by(FlowFilter { host: Some("STRIPE".into()), ..Default::default() }), 1);
|
||||||
assert_eq!(by(FlowFilter { host: Some("example.com".into()), ..Default::default() }), 2);
|
assert_eq!(by(FlowFilter { host: Some("example.com".into()), ..Default::default() }), 2);
|
||||||
assert_eq!(by(FlowFilter { host: Some("charges".into()), ..Default::default() }), 0);
|
assert_eq!(by(FlowFilter { host: Some("charges".into()), ..Default::default() }), 0);
|
||||||
|
assert_eq!(by(FlowFilter { host: Some("8443".into()), ..Default::default() }), 1);
|
||||||
assert_eq!(by(FlowFilter { method: Some("get".into()), ..Default::default() }), 2);
|
assert_eq!(by(FlowFilter { method: Some("get".into()), ..Default::default() }), 2);
|
||||||
assert_eq!(by(FlowFilter { status: Some("404".into()), ..Default::default() }), 1);
|
assert_eq!(by(FlowFilter { status: Some("404".into()), ..Default::default() }), 1);
|
||||||
assert_eq!(by(FlowFilter { status: Some("2xx".into()), ..Default::default() }), 1);
|
assert_eq!(by(FlowFilter { status: Some("2xx".into()), ..Default::default() }), 1);
|
||||||
assert_eq!(by(FlowFilter { status: Some("5XX".into()), ..Default::default() }), 0);
|
assert_eq!(by(FlowFilter { status: Some("5XX".into()), ..Default::default() }), 0);
|
||||||
|
assert_eq!(by(FlowFilter { status: Some("40".into()), ..Default::default() }), 0);
|
||||||
assert_eq!(by(FlowFilter { status: Some("error".into()), ..Default::default() }), 1);
|
assert_eq!(by(FlowFilter { status: Some("error".into()), ..Default::default() }), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_matches_url_method_status_and_content_type() {
|
||||||
|
let json = |url: &str, method: &str, status| Flow {
|
||||||
|
content_type: Some("application/json; charset=utf-8".into()),
|
||||||
|
..flow(url, method, Some(status), None)
|
||||||
|
};
|
||||||
|
let (_dir, db) = db_of(vec![
|
||||||
|
json("https://api.stripe.com/v1/charges?customer=cus_9", "POST", 200),
|
||||||
|
json("https://api.github.com/repos/yaak", "GET", 404),
|
||||||
|
Flow {
|
||||||
|
content_type: Some("image/png".into()),
|
||||||
|
..flow("https://images.ctfassets.net/logo.png", "GET", Some(200), None)
|
||||||
|
},
|
||||||
|
flow("http://localhost:3000/health", "HEAD", Some(503), None),
|
||||||
|
]);
|
||||||
|
let search = |q: &str| {
|
||||||
|
let filter = FlowFilter { q: Some(q.into()), ..Default::default() };
|
||||||
|
let mut found = urls(&db, filter);
|
||||||
|
found.sort();
|
||||||
|
found
|
||||||
|
};
|
||||||
|
let stripe = "https://api.stripe.com/v1/charges?customer=cus_9";
|
||||||
|
let github = "https://api.github.com/repos/yaak";
|
||||||
|
let logo = "https://images.ctfassets.net/logo.png";
|
||||||
|
let health = "http://localhost:3000/health";
|
||||||
|
// The host, the path and the query, any case.
|
||||||
|
assert_eq!(search("STRIPE"), vec![stripe]);
|
||||||
|
assert_eq!(search("/repos/"), vec![github]);
|
||||||
|
assert_eq!(search("cus_9"), vec![stripe]);
|
||||||
|
// The method, whole.
|
||||||
|
assert_eq!(search("head"), vec![health]);
|
||||||
|
assert_eq!(search("ead"), Vec::<&str>::new());
|
||||||
|
// A status, its start, or its class.
|
||||||
|
assert_eq!(search("404"), vec![github]);
|
||||||
|
assert_eq!(search("50"), vec![health]);
|
||||||
|
assert_eq!(search("2xx"), vec![stripe, logo]);
|
||||||
|
// The content type.
|
||||||
|
assert_eq!(search("png"), vec![logo]);
|
||||||
|
assert_eq!(search("json"), vec![github, stripe]);
|
||||||
|
// Every term must match, each anywhere.
|
||||||
|
assert_eq!(search("json get"), vec![github]);
|
||||||
|
assert_eq!(search(" json 2xx "), vec![stripe]);
|
||||||
|
assert_eq!(search("json 5xx"), Vec::<&str>::new());
|
||||||
|
// `%` and `_` are plain characters, not patterns.
|
||||||
|
assert_eq!(search("%"), Vec::<&str>::new());
|
||||||
|
assert_eq!(search("cus_"), vec![stripe]);
|
||||||
|
assert_eq!(search(""), vec![health, github, stripe, logo]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn under_matches_a_host_or_a_path_as_the_sidebar_does() {
|
||||||
|
let (_dir, db) = db_of(vec![
|
||||||
|
flow("https://api.stripe.com/v1/charges", "GET", Some(200), None),
|
||||||
|
flow("https://api.stripe.com/v1/charges/ch_1?expand=x", "GET", Some(200), None),
|
||||||
|
flow("https://api.stripe.com/v1/chargesx", "GET", Some(200), None),
|
||||||
|
flow("https://api.stripe.com:8443/v1/charges", "GET", Some(200), None),
|
||||||
|
flow("http://api.stripe.com", "GET", Some(200), None),
|
||||||
|
flow("https://api.stripe.community/", "GET", Some(200), None),
|
||||||
|
flow("https://example.com/v1//users", "GET", Some(200), None),
|
||||||
|
flow("http://x.com:80/a", "GET", Some(200), None),
|
||||||
|
flow("https://x.com:443/a", "GET", Some(200), None),
|
||||||
|
]);
|
||||||
|
let under = |nodes: &[&str]| {
|
||||||
|
let nodes = nodes.iter().map(|n| n.to_string()).collect();
|
||||||
|
urls(&db, FlowFilter { under: Some(nodes), ..Default::default() }).len()
|
||||||
|
};
|
||||||
|
// A host on another port is a node of its own, as in the sidebar.
|
||||||
|
assert_eq!(under(&["api.stripe.com"]), 4);
|
||||||
|
assert_eq!(under(&["api.stripe.com:8443"]), 1);
|
||||||
|
assert_eq!(under(&["api.stripe.com/v1/charges"]), 2);
|
||||||
|
// Empty segments and default ports are left out, as the sidebar leaves them.
|
||||||
|
assert_eq!(under(&["example.com/v1/users"]), 1);
|
||||||
|
assert_eq!(under(&["x.com/a"]), 2);
|
||||||
|
assert_eq!(under(&["api.stripe.com/v1/charges", "example.com"]), 3);
|
||||||
|
assert_eq!(under(&[]), 0);
|
||||||
|
// The sidebar lowercases hosts; paths keep their case.
|
||||||
|
assert_eq!(under(&["API.stripe.com/v1/charges"]), 2);
|
||||||
|
assert_eq!(under(&["api.stripe.com/V1/charges"]), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cases `lib/search.test.ts` checks `sidebarPath` against, so the two agree.
|
||||||
|
#[test]
|
||||||
|
fn sidebar_path_rules() {
|
||||||
|
for (url, path) in [
|
||||||
|
("https://example.com/v1//users", "example.com/v1/users"),
|
||||||
|
("http://x.com:80/a", "x.com/a"),
|
||||||
|
("https://x.com:443/a?q=1#f", "x.com/a"),
|
||||||
|
("http://x.com:443/a", "x.com:443/a"),
|
||||||
|
("https://API.Stripe.com:8443/", "api.stripe.com:8443"),
|
||||||
|
("https://api.stripe.com", "api.stripe.com"),
|
||||||
|
("https://api.stripe.com?x=/y", "api.stripe.com"),
|
||||||
|
("http://[::1]:80/a/B", "[::1]/a/B"),
|
||||||
|
("/api", "/api"),
|
||||||
|
] {
|
||||||
|
assert_eq!(sidebar_path(url), path, "{url}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_matches_a_bundle_id_or_a_path() {
|
||||||
|
let from = |bundle_id: Option<&str>, path: &str| Flow {
|
||||||
|
client_bundle_id: bundle_id.map(String::from),
|
||||||
|
client_path: Some(path.into()),
|
||||||
|
..flow("https://example.com/", "GET", Some(200), None)
|
||||||
|
};
|
||||||
|
let (_dir, db) = db_of(vec![
|
||||||
|
from(Some("com.google.Chrome"), "/Applications/Google Chrome.app"),
|
||||||
|
from(Some("com.google.Chrome"), "/Applications/Google Chrome.app"),
|
||||||
|
from(None, "/usr/bin/curl"),
|
||||||
|
flow("https://example.com/", "GET", Some(200), None),
|
||||||
|
]);
|
||||||
|
let app = |apps: &[&str]| {
|
||||||
|
let apps = apps.iter().map(|a| a.to_string()).collect();
|
||||||
|
urls(&db, FlowFilter { app: Some(apps), ..Default::default() }).len()
|
||||||
|
};
|
||||||
|
assert_eq!(app(&["com.google.Chrome"]), 2);
|
||||||
|
assert_eq!(app(&["/usr/bin/curl"]), 1);
|
||||||
|
assert_eq!(app(&["/Applications/Google Chrome.app"]), 0);
|
||||||
|
assert_eq!(app(&["com.google.Chrome", "/usr/bin/curl"]), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_search_pages_back_and_counts_what_it_matches() {
|
||||||
|
// Every third flow is a POST, newest last.
|
||||||
|
let (_dir, db) = db_of(
|
||||||
|
(0..30)
|
||||||
|
.map(|i| {
|
||||||
|
let method = if i % 3 == 0 { "POST" } else { "GET" };
|
||||||
|
flow(&format!("https://api.example.com/{i}"), method, Some(200), None)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
let search = |before: Option<String>| FlowFilter {
|
||||||
|
q: Some("post".into()),
|
||||||
|
before,
|
||||||
|
limit: Some(4),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let (first, total, matching) = page(&db, &search(None)).unwrap();
|
||||||
|
let ids = |flows: &[Flow]| flows.iter().map(|f| f.id.clone()).collect::<Vec<_>>();
|
||||||
|
assert_eq!(ids(&first), vec!["fl_027", "fl_024", "fl_021", "fl_018"]);
|
||||||
|
assert_eq!((total, matching), (30, Some(10)));
|
||||||
|
let (second, _, _) = page(&db, &search(Some("fl_018".into()))).unwrap();
|
||||||
|
assert_eq!(ids(&second), vec!["fl_015", "fl_012", "fl_009", "fl_006"]);
|
||||||
|
let (last, _, _) = page(&db, &search(Some("fl_006".into()))).unwrap();
|
||||||
|
assert_eq!(ids(&last), vec!["fl_003", "fl_000"]);
|
||||||
|
// Without a narrowing filter there is nothing more to count.
|
||||||
|
let (_, _, matching) =
|
||||||
|
page(&db, &FlowFilter { limit: Some(1), ..Default::default() }).unwrap();
|
||||||
|
assert_eq!(matching, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_search_reads_the_newest_page_through_the_index() {
|
||||||
|
let (_dir, db) = db_of(vec![]);
|
||||||
|
let plan: Vec<String> = db.with_conn(|c| {
|
||||||
|
let conn = c.conn().resolve();
|
||||||
|
let (condition, params) =
|
||||||
|
FlowFilter { q: Some("stripe".into()), ..Default::default() }.condition();
|
||||||
|
let sql = format!(
|
||||||
|
"EXPLAIN QUERY PLAN SELECT * FROM flows WHERE {condition} \
|
||||||
|
ORDER BY started_at DESC, id DESC LIMIT 500"
|
||||||
|
);
|
||||||
|
let mut stmt = conn.prepare(&sql).unwrap();
|
||||||
|
stmt.query_map(rusqlite::params_from_iter(params), |r| r.get::<_, String>(3))
|
||||||
|
.unwrap()
|
||||||
|
.map(|r| r.unwrap())
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
assert!(plan.iter().any(|p| p.contains("idx_flows_started_at_id")), "{plan:?}");
|
||||||
|
assert!(!plan.iter().any(|p| p.contains("TEMP B-TREE")), "{plan:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn after_follows_flows_as_they_are_recorded() {
|
fn after_follows_flows_as_they_are_recorded() {
|
||||||
use yaak_database::UpdateSource;
|
use yaak_database::UpdateSource;
|
||||||
@@ -412,12 +753,19 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn query_round_trip() {
|
fn query_round_trip() {
|
||||||
let filter = FlowFilter {
|
let filter = FlowFilter {
|
||||||
|
q: Some("post json".into()),
|
||||||
host: Some("api.stripe.com".into()),
|
host: Some("api.stripe.com".into()),
|
||||||
|
under: Some(vec!["api.stripe.com/v1".into(), "example.com".into()]),
|
||||||
|
app: Some(vec!["com.google.Chrome".into()]),
|
||||||
status: Some("4xx".into()),
|
status: Some("4xx".into()),
|
||||||
limit: Some(20),
|
limit: Some(20),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert_eq!(filter.to_query(), "host=api.stripe.com&status=4xx&limit=20");
|
assert_eq!(
|
||||||
|
filter.to_query(),
|
||||||
|
"q=post+json&host=api.stripe.com&under=api.stripe.com%2Fv1&under=example.com\
|
||||||
|
&app=com.google.Chrome&status=4xx&limit=20"
|
||||||
|
);
|
||||||
assert_eq!(FlowFilter::from_query(&filter.to_query()).unwrap(), filter);
|
assert_eq!(FlowFilter::from_query(&filter.to_query()).unwrap(), filter);
|
||||||
assert_eq!(FlowFilter::from_query("").unwrap(), FlowFilter::default());
|
assert_eq!(FlowFilter::from_query("").unwrap(), FlowFilter::default());
|
||||||
assert!(FlowFilter::from_query("limit=lots").is_err());
|
assert!(FlowFilter::from_query("limit=lots").is_err());
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod actions;
|
pub mod actions;
|
||||||
|
pub mod attribution;
|
||||||
pub mod bodies;
|
pub mod bodies;
|
||||||
pub mod ca;
|
pub mod ca;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
@@ -13,8 +14,11 @@ pub mod save;
|
|||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|
||||||
use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction};
|
use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction};
|
||||||
|
use crate::attribution::Attribution;
|
||||||
use crate::ca::CaStatus;
|
use crate::ca::CaStatus;
|
||||||
use crate::control::{ControlFile, ControlInfo, Owner};
|
use crate::client::ControlClient;
|
||||||
|
use crate::control::{ControlFile, ControlInfo, ControlServer, Owner};
|
||||||
|
use crate::convert::DroppedHeader;
|
||||||
use crate::db::ProxyQueryManager;
|
use crate::db::ProxyQueryManager;
|
||||||
use crate::macos::{SystemProxyBackend, SystemProxyStatus};
|
use crate::macos::{SystemProxyBackend, SystemProxyStatus};
|
||||||
use crate::models::{Flow, FlowBody, FlowIden, ModelPayload, ProxyHeader, find_header};
|
use crate::models::{Flow, FlowBody, FlowIden, ModelPayload, ProxyHeader, find_header};
|
||||||
@@ -26,10 +30,12 @@ use chrono::{DateTime, Utc};
|
|||||||
use log::{debug, warn};
|
use log::{debug, warn};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::io::Write;
|
||||||
use std::net::{SocketAddr, TcpStream};
|
use std::net::{SocketAddr, TcpStream};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::Duration;
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::time::{Duration, SystemTime};
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
use yaak_database::{ModelChangeEvent, UpdateSource, UpsertModelInfo};
|
use yaak_database::{ModelChangeEvent, UpdateSource, UpsertModelInfo};
|
||||||
use yaak_models::blob_manager::{BlobManager, BodyChunkWriter};
|
use yaak_models::blob_manager::{BlobManager, BodyChunkWriter};
|
||||||
@@ -46,10 +52,19 @@ pub struct ProxyCtx {
|
|||||||
owner: Owner,
|
owner: Owner,
|
||||||
client_data_dir: PathBuf,
|
client_data_dir: PathBuf,
|
||||||
control: Arc<Mutex<Option<ControlFile>>>,
|
control: Arc<Mutex<Option<ControlFile>>>,
|
||||||
|
/// The control API this app serves while it owns the engine. Not the CLI daemon's, which
|
||||||
|
/// holds its own.
|
||||||
|
control_server: Arc<Mutex<Option<ControlServer>>>,
|
||||||
/// Held through each system proxy change, password dialog included, so they apply in turn.
|
/// Held through each system proxy change, password dialog included, so they apply in turn.
|
||||||
system_proxy_turn: Arc<Mutex<()>>,
|
system_proxy_turn: Arc<Mutex<()>>,
|
||||||
/// The port the engine last ran on, which the system proxy may still point at.
|
/// The port the engine last ran on, which the system proxy may still point at.
|
||||||
last_port: Arc<Mutex<Option<u16>>>,
|
last_port: Arc<Mutex<Option<u16>>>,
|
||||||
|
/// Flows are stored. While paused, traffic still passes through, untouched and unrecorded.
|
||||||
|
recording: Arc<Mutex<RecordingLog>>,
|
||||||
|
/// This Mac's web traffic is meant to point at the running engine.
|
||||||
|
system_proxy_on: Arc<AtomicBool>,
|
||||||
|
/// Why the engine last failed to start, until it starts.
|
||||||
|
start_error: Arc<Mutex<Option<String>>>,
|
||||||
pub db: ProxyQueryManager,
|
pub db: ProxyQueryManager,
|
||||||
pub blobs: BlobManager,
|
pub blobs: BlobManager,
|
||||||
pub events: RpcEventEmitter,
|
pub events: RpcEventEmitter,
|
||||||
@@ -68,8 +83,12 @@ impl ProxyCtx {
|
|||||||
owner: Owner::App,
|
owner: Owner::App,
|
||||||
client_data_dir: save::client_data_dir(),
|
client_data_dir: save::client_data_dir(),
|
||||||
control: Arc::new(Mutex::new(None)),
|
control: Arc::new(Mutex::new(None)),
|
||||||
|
control_server: Arc::new(Mutex::new(None)),
|
||||||
system_proxy_turn: Arc::new(Mutex::new(())),
|
system_proxy_turn: Arc::new(Mutex::new(())),
|
||||||
last_port: Arc::new(Mutex::new(None)),
|
last_port: Arc::new(Mutex::new(None)),
|
||||||
|
recording: Arc::new(Mutex::new(RecordingLog::default())),
|
||||||
|
system_proxy_on: Arc::new(AtomicBool::new(false)),
|
||||||
|
start_error: Arc::new(Mutex::new(None)),
|
||||||
db,
|
db,
|
||||||
blobs,
|
blobs,
|
||||||
events,
|
events,
|
||||||
@@ -115,8 +134,26 @@ impl ProxyCtx {
|
|||||||
|
|
||||||
/// Starts the engine on `port`, or the one in `settings.json`. Does nothing if it is
|
/// Starts the engine on `port`, or the one in `settings.json`. Does nothing if it is
|
||||||
/// running. Points the system proxy at it when the settings ask for that. Refused while
|
/// running. Points the system proxy at it when the settings ask for that. Refused while
|
||||||
/// another process owns the engine, so there are never two.
|
/// another process owns the engine, so there are never two. A failure is kept until the
|
||||||
|
/// next start, for the app to show.
|
||||||
pub fn start_on(&self, port: Option<u16>) -> Result<(), String> {
|
pub fn start_on(&self, port: Option<u16>) -> Result<(), String> {
|
||||||
|
let started = self.start_engine(port);
|
||||||
|
if let Ok(mut start_error) = self.start_error.lock() {
|
||||||
|
*start_error = started.as_ref().err().cloned();
|
||||||
|
}
|
||||||
|
if started.is_err() {
|
||||||
|
self.events
|
||||||
|
.emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Stopped });
|
||||||
|
}
|
||||||
|
started
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why the engine last failed to start, if it has not started since.
|
||||||
|
pub fn start_error(&self) -> Option<String> {
|
||||||
|
self.start_error.lock().ok()?.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_engine(&self, port: Option<u16>) -> Result<(), String> {
|
||||||
if let Some(owner) = control::live_owner(&self.data_dir)
|
if let Some(owner) = control::live_owner(&self.data_dir)
|
||||||
&& owner.pid != std::process::id()
|
&& owner.pid != std::process::id()
|
||||||
{
|
{
|
||||||
@@ -132,13 +169,30 @@ impl ProxyCtx {
|
|||||||
.map_err(|e| format!("Failed to load proxy CA: {e}"))?;
|
.map_err(|e| format!("Failed to load proxy CA: {e}"))?;
|
||||||
let mut config = ProxyConfig::new(port.unwrap_or(settings.port), Arc::new(ca));
|
let mut config = ProxyConfig::new(port.unwrap_or(settings.port), Arc::new(ca));
|
||||||
config.capture_body_limit = settings.capture_body_limit;
|
config.capture_body_limit = settings.capture_body_limit;
|
||||||
|
// Filled once the engine has bound, since the lookup matches sockets by its port.
|
||||||
|
let attribution_slot: Arc<OnceLock<Attribution>> = Arc::new(OnceLock::new());
|
||||||
|
let slot = attribution_slot.clone();
|
||||||
|
config.on_accept = Some(Arc::new(move |connection, client_addr| {
|
||||||
|
// A client that is answered quickly may exit soon after, so it is found before
|
||||||
|
// anything is read from it: a few milliseconds once per connection at most.
|
||||||
|
if let Some(attribution) = slot.get() {
|
||||||
|
attribution.begin(connection, client_addr);
|
||||||
|
attribution.get(connection, ACCEPT_ATTRIBUTION_WAIT);
|
||||||
|
}
|
||||||
|
}));
|
||||||
let mut proxy_handle = yaak_proxy::start_proxy(config)?;
|
let mut proxy_handle = yaak_proxy::start_proxy(config)?;
|
||||||
|
let attribution = Attribution::new(proxy_handle.port);
|
||||||
|
let _ = attribution_slot.set(attribution.clone());
|
||||||
|
|
||||||
if let Some(event_rx) = proxy_handle.take_event_rx() {
|
if let Some(event_rx) = proxy_handle.take_event_rx() {
|
||||||
let db = self.db.clone();
|
let sink = FlowSink {
|
||||||
let blobs = self.blobs.clone();
|
db: self.db.clone(),
|
||||||
let events = self.events.clone();
|
blobs: self.blobs.clone(),
|
||||||
std::thread::spawn(move || run_event_loop(event_rx, db, blobs, events));
|
events: self.events.clone(),
|
||||||
|
attribution,
|
||||||
|
recording: self.recording.clone(),
|
||||||
|
};
|
||||||
|
std::thread::spawn(move || run_event_loop(event_rx, sink));
|
||||||
}
|
}
|
||||||
|
|
||||||
let bound = proxy_handle.port;
|
let bound = proxy_handle.port;
|
||||||
@@ -148,13 +202,18 @@ impl ProxyCtx {
|
|||||||
if let Ok(mut last_port) = self.last_port.lock() {
|
if let Ok(mut last_port) = self.last_port.lock() {
|
||||||
*last_port = Some(bound);
|
*last_port = Some(bound);
|
||||||
}
|
}
|
||||||
if settings.system_proxy {
|
self.system_proxy_on.store(settings.system_proxy_at_launch, Ordering::SeqCst);
|
||||||
|
if settings.system_proxy_at_launch {
|
||||||
// The password dialog can sit open for a while; the engine is up regardless.
|
// The password dialog can sit open for a while; the engine is up regardless.
|
||||||
let ctx = self.clone();
|
let ctx = self.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
if let Err(e) = ctx.change_system_proxy(true, bound, true) {
|
if let Err(e) = ctx.change_system_proxy(true, bound, true) {
|
||||||
warn!("Failed to turn on the system proxy: {e}");
|
warn!("Failed to turn on the system proxy: {e}");
|
||||||
|
ctx.system_proxy_on.store(false, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
// So the app reads the system proxy again once the dialog is answered.
|
||||||
|
ctx.events
|
||||||
|
.emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Running });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
self.events.emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Running });
|
self.events.emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Running });
|
||||||
@@ -163,6 +222,7 @@ impl ProxyCtx {
|
|||||||
|
|
||||||
/// Stops the engine and turns off a system proxy that points at it.
|
/// Stops the engine and turns off a system proxy that points at it.
|
||||||
pub fn stop(&self) -> Result<(), String> {
|
pub fn stop(&self) -> Result<(), String> {
|
||||||
|
self.system_proxy_on.store(false, Ordering::SeqCst);
|
||||||
let port = self.take_handle()?;
|
let port = self.take_handle()?;
|
||||||
if let Some(port) = port {
|
if let Some(port) = port {
|
||||||
let ctx = self.clone();
|
let ctx = self.clone();
|
||||||
@@ -218,6 +278,83 @@ impl ProxyCtx {
|
|||||||
self.control.lock().ok()?.as_ref().map(|c| c.info.clone())
|
self.control.lock().ok()?.as_ref().map(|c| c.info.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Claims the engine for this app and serves the control API, unless it already does.
|
||||||
|
/// Fails while another live owner, the CLI daemon, has it. Resets a system proxy a crashed
|
||||||
|
/// owner left pointing at its port.
|
||||||
|
pub fn serve_control_api(&self) -> Result<(), String> {
|
||||||
|
let mut slot = self.control_server.lock().map_err(|_| "lock poisoned".to_string())?;
|
||||||
|
if slot.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Read first: a crashed owner's file names the port its system proxy may still point at.
|
||||||
|
let previous = control::read(&self.data_dir);
|
||||||
|
*slot = Some(ControlServer::start(self.clone())?);
|
||||||
|
drop(slot);
|
||||||
|
|
||||||
|
let mut ports: Vec<u16> = previous.and_then(|p| p.proxy_port).into_iter().collect();
|
||||||
|
ports.extend(self.settings().ok().map(|s| s.port));
|
||||||
|
let ctx = self.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Err(e) = ctx.reset_stale_system_proxy(&ports) {
|
||||||
|
warn!("Failed to reset a stale system proxy: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops serving the control API and gives up the claim on the engine.
|
||||||
|
pub fn stop_control_api(&self) {
|
||||||
|
let server = self.control_server.lock().ok().and_then(|mut s| s.take());
|
||||||
|
drop(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Another live owner of the engine, such as the CLI daemon.
|
||||||
|
pub fn other_owner(&self) -> Option<ControlInfo> {
|
||||||
|
let ours = self.control_info().map(|c| c.token);
|
||||||
|
control::live_owner(&self.data_dir).filter(|o| Some(&o.token) != ours.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops the engine in the process that owns it, waits for that process to give it up,
|
||||||
|
/// then claims it here and starts it. Fails, leaving things as they are, when the owner
|
||||||
|
/// does not let go.
|
||||||
|
pub async fn take_over(&self) -> Result<(), String> {
|
||||||
|
if let Some(owner) = self.other_owner() {
|
||||||
|
// Stopped through the control API, another app halts its engine but keeps the claim,
|
||||||
|
// so only the CLI daemon, which exits on it, can be taken over.
|
||||||
|
if owner.owner == Owner::App {
|
||||||
|
return Err(ANOTHER_APP_OWNS.to_string());
|
||||||
|
}
|
||||||
|
ControlClient::new(owner.clone()).stop().await?;
|
||||||
|
let ctx = self.clone();
|
||||||
|
let released = tokio::task::spawn_blocking(move || {
|
||||||
|
let deadline = std::time::Instant::now() + TAKE_OVER_WAIT;
|
||||||
|
while std::time::Instant::now() < deadline {
|
||||||
|
if ctx.other_owner().is_none() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
false
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if !released {
|
||||||
|
return Err(format!(
|
||||||
|
"The proxy in {} (pid {}) stopped but did not let go of it",
|
||||||
|
control::owner_name(owner.owner),
|
||||||
|
owner.pid
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ctx = self.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
ctx.serve_control_api()?;
|
||||||
|
ctx.start()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
fn set_control_proxy_port(&self, port: Option<u16>) {
|
fn set_control_proxy_port(&self, port: Option<u16>) {
|
||||||
if let Ok(mut control) = self.control.lock()
|
if let Ok(mut control) = self.control.lock()
|
||||||
&& let Some(control) = control.as_mut()
|
&& let Some(control) = control.as_mut()
|
||||||
@@ -240,23 +377,59 @@ impl ProxyCtx {
|
|||||||
macos::status(port, self.system_proxy_backend())
|
macos::status(port, self.system_proxy_backend())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Turns the system proxy on or off and remembers the choice. Turned on while the engine
|
/// Points this Mac's web traffic at the running engine, or stops, at once, through the
|
||||||
/// is stopped, it waits for the engine to start, so the Mac is never pointed at nothing.
|
/// password dialog. Not saved: the `systemProxyAtLaunch` setting decides whether it turns
|
||||||
/// Blocks, through the password dialog.
|
/// on as the engine starts. Blocks.
|
||||||
pub fn set_system_proxy(&self, enabled: bool) -> Result<SystemProxyStatus, String> {
|
pub fn set_system_proxy(&self, enabled: bool) -> Result<SystemProxyStatus, String> {
|
||||||
let mut settings = self.settings()?;
|
let Some(port) = self.port() else {
|
||||||
let previous = settings.system_proxy;
|
if enabled {
|
||||||
settings.system_proxy = enabled;
|
return Err("The proxy is not running, so there is nothing to route to".into());
|
||||||
settings.save(&self.settings_path())?;
|
}
|
||||||
let port = self.port().unwrap_or(settings.port);
|
// Clears one left pointing at the configured port; asks for nothing otherwise.
|
||||||
|
self.change_system_proxy(false, self.settings()?.port, false)?;
|
||||||
|
return self.system_proxy_status();
|
||||||
|
};
|
||||||
|
let previous = self.system_proxy_on.swap(enabled, Ordering::SeqCst);
|
||||||
if let Err(e) = self.change_system_proxy(enabled, port, enabled) {
|
if let Err(e) = self.change_system_proxy(enabled, port, enabled) {
|
||||||
settings.system_proxy = previous;
|
self.system_proxy_on.store(previous, Ordering::SeqCst);
|
||||||
let _ = settings.save(&self.settings_path());
|
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
self.system_proxy_status()
|
self.system_proxy_status()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the system proxy turns on as the engine starts, saved in `settings.json`.
|
||||||
|
pub fn set_system_proxy_at_launch(&self, enabled: bool) -> Result<(), String> {
|
||||||
|
let mut settings = self.settings()?;
|
||||||
|
settings.system_proxy_at_launch = enabled;
|
||||||
|
settings.save(&self.settings_path())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Intercepts again the hosts passed through after clients rejected the CA, as they no
|
||||||
|
/// longer will once it is trusted.
|
||||||
|
pub fn forget_passthrough(&self) {
|
||||||
|
if let Ok(handle) = self.handle.lock()
|
||||||
|
&& let Some(handle) = handle.as_ref()
|
||||||
|
{
|
||||||
|
handle.forget_passthrough();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flows are being stored.
|
||||||
|
pub fn recording(&self) -> bool {
|
||||||
|
self.recording.lock().map(|r| r.is_on()).unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resumes or pauses storing flows. Paused, traffic still passes through the engine, but no
|
||||||
|
/// new flow is stored; one already under way is finished.
|
||||||
|
pub fn set_recording(&self, recording: bool) {
|
||||||
|
let changed = self.recording.lock().map(|mut r| r.set(recording)).unwrap_or(false);
|
||||||
|
if changed {
|
||||||
|
let state =
|
||||||
|
if self.port().is_some() { ProxyState::Running } else { ProxyState::Stopped };
|
||||||
|
self.events.emit("proxy_state_changed", &ProxyStatePayload { state });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Points the system proxy at `port`, or turns it off there, once the changes before it
|
/// Points the system proxy at `port`, or turns it off there, once the changes before it
|
||||||
/// are done. With `follow_engine`, the change is dropped if the engine has moved on by its
|
/// are done. With `follow_engine`, the change is dropped if the engine has moved on by its
|
||||||
/// turn: an enable once the engine is off `port` or the setting is off, a disable once the
|
/// turn: an enable once the engine is off `port` or the setting is off, a disable once the
|
||||||
@@ -271,7 +444,8 @@ impl ProxyCtx {
|
|||||||
let _turn = self.system_proxy_turn.lock().map_err(|_| "lock poisoned".to_string())?;
|
let _turn = self.system_proxy_turn.lock().map_err(|_| "lock poisoned".to_string())?;
|
||||||
if follow_engine {
|
if follow_engine {
|
||||||
let on_port = self.port() == Some(port);
|
let on_port = self.port() == Some(port);
|
||||||
let current = if enabled { on_port && self.settings()?.system_proxy } else { !on_port };
|
let wanted = self.system_proxy_on.load(Ordering::SeqCst);
|
||||||
|
let current = if enabled { on_port && wanted } else { !on_port };
|
||||||
if !current {
|
if !current {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -302,6 +476,11 @@ impl ProxyCtx {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Yaak has run on this machine, so there is a database to save into.
|
||||||
|
pub fn client_exists(&self) -> bool {
|
||||||
|
ClientDb::exists(&self.client_data_dir)
|
||||||
|
}
|
||||||
|
|
||||||
/// Yaak's workspaces and their folders, to save a flow into.
|
/// Yaak's workspaces and their folders, to save a flow into.
|
||||||
pub fn workspaces(&self) -> Result<Vec<WorkspaceSummary>, String> {
|
pub fn workspaces(&self) -> Result<Vec<WorkspaceSummary>, String> {
|
||||||
ClientDb::open(&self.client_data_dir)?.workspaces()
|
ClientDb::open(&self.client_data_dir)?.workspaces()
|
||||||
@@ -354,6 +533,52 @@ fn is_listening(port: u16) -> bool {
|
|||||||
TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok()
|
TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Recording --
|
||||||
|
|
||||||
|
/// Changes to recording kept, so a request whose events are read late is still judged by when
|
||||||
|
/// it started.
|
||||||
|
const RECORDING_CHANGES: usize = 64;
|
||||||
|
|
||||||
|
/// Whether flows are stored, and when that changed.
|
||||||
|
struct RecordingLog {
|
||||||
|
/// Oldest first, starting from recording since the beginning.
|
||||||
|
changes: std::collections::VecDeque<(SystemTime, bool)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RecordingLog {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { changes: [(SystemTime::UNIX_EPOCH, true)].into() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordingLog {
|
||||||
|
fn is_on(&self) -> bool {
|
||||||
|
self.changes.back().is_none_or(|(_, on)| *on)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether it changed.
|
||||||
|
fn set(&mut self, on: bool) -> bool {
|
||||||
|
if self.is_on() == on {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.changes.push_back((SystemTime::now(), on));
|
||||||
|
while self.changes.len() > RECORDING_CHANGES {
|
||||||
|
self.changes.pop_front();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether flows were stored at `time`.
|
||||||
|
fn was_on_at(&self, time: SystemTime) -> bool {
|
||||||
|
self.changes
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|(at, _)| *at <= time)
|
||||||
|
.or(self.changes.front())
|
||||||
|
.is_none_or(|(_, on)| *on)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -- Proxy state --
|
// -- Proxy state --
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||||
@@ -391,6 +616,9 @@ pub struct ListFlowsResponse {
|
|||||||
/// Flows in the database, whatever the filter.
|
/// Flows in the database, whatever the filter.
|
||||||
#[ts(type = "number")]
|
#[ts(type = "number")]
|
||||||
pub total: usize,
|
pub total: usize,
|
||||||
|
/// Flows the filter covers, whatever the limit, when it narrows to fewer than all of them.
|
||||||
|
#[ts(type = "number | null")]
|
||||||
|
pub matching: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, TS)]
|
#[derive(Deserialize, TS)]
|
||||||
@@ -412,6 +640,130 @@ pub struct GetProxyStateResponse {
|
|||||||
pub state: ProxyState,
|
pub state: ProxyState,
|
||||||
/// The port the engine is bound to, while it runs.
|
/// The port the engine is bound to, while it runs.
|
||||||
pub port: Option<u16>,
|
pub port: Option<u16>,
|
||||||
|
/// The port the engine starts on.
|
||||||
|
pub configured_port: u16,
|
||||||
|
/// Flows are stored. Paused, traffic still passes through, unrecorded.
|
||||||
|
pub recording: bool,
|
||||||
|
/// The system proxy turns on as the engine starts.
|
||||||
|
pub system_proxy_at_launch: bool,
|
||||||
|
/// Why the engine last failed to start, until it starts.
|
||||||
|
pub start_error: Option<String>,
|
||||||
|
/// Another process that owns the engine, such as the CLI daemon. While one does, this app
|
||||||
|
/// cannot start its own, and new flows reach it only through the database.
|
||||||
|
pub other_owner: Option<OtherOwner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OtherOwner {
|
||||||
|
pub owner: Owner,
|
||||||
|
pub pid: u32,
|
||||||
|
pub started_at: DateTime<Utc>,
|
||||||
|
/// The engine's port while it runs.
|
||||||
|
pub proxy_port: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, TS)]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
|
pub struct TakeOverRequest {}
|
||||||
|
|
||||||
|
#[derive(Deserialize, TS)]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
|
pub struct SetRecordingRequest {
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, TS)]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
|
pub struct SetSystemProxyAtLaunchRequest {
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, TS)]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
|
pub struct AppIconRequest {
|
||||||
|
/// A flow's `clientPath`.
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, TS)]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
|
pub struct ListWorkspacesRequest {}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ListWorkspacesResponse {
|
||||||
|
/// Yaak has run on this machine. Without it there is nothing to save into.
|
||||||
|
pub yaak_found: bool,
|
||||||
|
pub workspaces: Vec<WorkspaceSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, TS)]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PreviewSaveRequest {
|
||||||
|
pub flow_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What saving a flow as a Yaak request writes, for the save dialog.
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SavePreview {
|
||||||
|
pub method: String,
|
||||||
|
pub url: String,
|
||||||
|
/// The URL as saved, its query split out into parameters, which Yaak shows for an unnamed
|
||||||
|
/// request.
|
||||||
|
pub request_url: String,
|
||||||
|
/// The saved body type, which Yaak shows for an unnamed GraphQL request with no URL.
|
||||||
|
pub body_type: Option<String>,
|
||||||
|
/// Headers Yaak sets itself or that only meant something to the proxy.
|
||||||
|
pub dropped_headers: Vec<DroppedHeader>,
|
||||||
|
/// Headers saved as captured, Authorization among them.
|
||||||
|
#[ts(type = "number")]
|
||||||
|
pub kept_headers: usize,
|
||||||
|
/// The body as it is saved: "JSON", "form URL-encoded with 7 fields". None without a body.
|
||||||
|
pub body: Option<String>,
|
||||||
|
/// What does not carry over exactly, one sentence each.
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
/// An Authorization header is saved in plain text.
|
||||||
|
pub authorization: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, TS)]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SaveFlowToYaakRequest {
|
||||||
|
pub flow_id: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
#[ts(optional)]
|
||||||
|
pub folder_id: Option<String>,
|
||||||
|
/// None or blank leaves the request unnamed, and Yaak shows its URL.
|
||||||
|
#[ts(optional)]
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SavedFlow {
|
||||||
|
pub request_id: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, TS)]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ExportHarRequest {
|
||||||
|
/// The file to write.
|
||||||
|
pub path: String,
|
||||||
|
/// These flows, or else every flow the filter covers.
|
||||||
|
#[ts(optional)]
|
||||||
|
pub ids: Option<Vec<String>>,
|
||||||
|
#[ts(optional)]
|
||||||
|
pub filter: Option<flows::FlowFilter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, TS)]
|
#[derive(Deserialize, TS)]
|
||||||
@@ -492,8 +844,7 @@ pub struct SystemProxySetRequest {
|
|||||||
fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool, RpcError> {
|
fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool, RpcError> {
|
||||||
match invocation {
|
match invocation {
|
||||||
ActionInvocation::Global { action } => match action {
|
ActionInvocation::Global { action } => match action {
|
||||||
GlobalAction::ProxyStart => ctx.start().map_err(|message| RpcError { message })?,
|
GlobalAction::RecordingToggle => ctx.set_recording(!ctx.recording()),
|
||||||
GlobalAction::ProxyStop => ctx.stop().map_err(|message| RpcError { message })?,
|
|
||||||
GlobalAction::FlowsClear => {
|
GlobalAction::FlowsClear => {
|
||||||
ctx.clear_flows().map_err(|message| RpcError { message })?;
|
ctx.clear_flows().map_err(|message| RpcError { message })?;
|
||||||
}
|
}
|
||||||
@@ -502,13 +853,137 @@ fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool,
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_proxy_state(
|
async fn get_proxy_state(
|
||||||
ctx: &ProxyCtx,
|
ctx: ProxyCtx,
|
||||||
_req: GetProxyStateRequest,
|
_req: GetProxyStateRequest,
|
||||||
) -> Result<GetProxyStateResponse, RpcError> {
|
) -> Result<GetProxyStateResponse, RpcError> {
|
||||||
let port = ctx.port();
|
// The owner check takes a file lock and a connection, so off the async threads.
|
||||||
let state = if port.is_some() { ProxyState::Running } else { ProxyState::Stopped };
|
blocking(move || {
|
||||||
Ok(GetProxyStateResponse { state, port })
|
let port = ctx.port();
|
||||||
|
let state = if port.is_some() { ProxyState::Running } else { ProxyState::Stopped };
|
||||||
|
let settings = ctx.settings()?;
|
||||||
|
let other_owner = ctx.other_owner().map(|o| OtherOwner {
|
||||||
|
owner: o.owner,
|
||||||
|
pid: o.pid,
|
||||||
|
started_at: o.started_at,
|
||||||
|
proxy_port: o.proxy_port,
|
||||||
|
});
|
||||||
|
Ok(GetProxyStateResponse {
|
||||||
|
state,
|
||||||
|
port,
|
||||||
|
configured_port: settings.port,
|
||||||
|
recording: ctx.recording(),
|
||||||
|
system_proxy_at_launch: settings.system_proxy_at_launch,
|
||||||
|
start_error: ctx.start_error(),
|
||||||
|
other_owner,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_recording(ctx: &ProxyCtx, req: SetRecordingRequest) -> Result<bool, RpcError> {
|
||||||
|
ctx.set_recording(req.enabled);
|
||||||
|
Ok(ctx.recording())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_system_proxy_at_launch(
|
||||||
|
ctx: &ProxyCtx,
|
||||||
|
req: SetSystemProxyAtLaunchRequest,
|
||||||
|
) -> Result<bool, RpcError> {
|
||||||
|
ctx.set_system_proxy_at_launch(req.enabled).map_err(rpc_error)?;
|
||||||
|
Ok(req.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn take_over(ctx: ProxyCtx, _req: TakeOverRequest) -> Result<bool, RpcError> {
|
||||||
|
ctx.take_over().await.map_err(rpc_error)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn app_icon(_ctx: ProxyCtx, req: AppIconRequest) -> Result<Option<String>, RpcError> {
|
||||||
|
let png = blocking(move || attribution::app_icon_png(&req.path)).await?;
|
||||||
|
Ok(png.map(|png| BASE64.encode(png)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_workspaces(
|
||||||
|
ctx: ProxyCtx,
|
||||||
|
_req: ListWorkspacesRequest,
|
||||||
|
) -> Result<ListWorkspacesResponse, RpcError> {
|
||||||
|
blocking(move || {
|
||||||
|
if !ctx.client_exists() {
|
||||||
|
return Ok(ListWorkspacesResponse { yaak_found: false, workspaces: Vec::new() });
|
||||||
|
}
|
||||||
|
Ok(ListWorkspacesResponse { yaak_found: true, workspaces: ctx.workspaces()? })
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn preview_save(ctx: ProxyCtx, req: PreviewSaveRequest) -> Result<SavePreview, RpcError> {
|
||||||
|
let flow = find_flow(&ctx, &req.flow_id)?;
|
||||||
|
let limit = settings(&ctx)?.capture_body_limit;
|
||||||
|
let body =
|
||||||
|
bodies::read_body(&ctx.blobs, &flow, FlowBody::Request, limit).await.map_err(rpc_error)?;
|
||||||
|
let conversion = convert::flow_to_request(&flow, &body);
|
||||||
|
let request = conversion.request;
|
||||||
|
let body = request.body_type.as_deref().map(|body_type| match body_type {
|
||||||
|
"application/json" => "JSON".to_string(),
|
||||||
|
"text/xml" => "XML".to_string(),
|
||||||
|
"graphql" => "GraphQL".to_string(),
|
||||||
|
"application/x-www-form-urlencoded" => {
|
||||||
|
let fields = request.body.get("form").and_then(|f| f.as_array()).map_or(0, Vec::len);
|
||||||
|
match fields {
|
||||||
|
1 => "form URL-encoded with 1 field".to_string(),
|
||||||
|
n => format!("form URL-encoded with {n} fields"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => "text".to_string(),
|
||||||
|
});
|
||||||
|
let authorization =
|
||||||
|
request.headers.iter().any(|h| h.name.eq_ignore_ascii_case("authorization"));
|
||||||
|
Ok(SavePreview {
|
||||||
|
method: flow.method.clone(),
|
||||||
|
url: flow.url.clone(),
|
||||||
|
request_url: request.url.clone(),
|
||||||
|
body_type: request.body_type.clone(),
|
||||||
|
dropped_headers: conversion.dropped_headers,
|
||||||
|
kept_headers: request.headers.len(),
|
||||||
|
body,
|
||||||
|
warnings: conversion.warnings,
|
||||||
|
authorization,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_flow(ctx: ProxyCtx, req: SaveFlowToYaakRequest) -> Result<SavedFlow, RpcError> {
|
||||||
|
let target = SaveFlowRequest {
|
||||||
|
workspace_id: req.workspace_id,
|
||||||
|
folder_id: req.folder_id,
|
||||||
|
name: req.name,
|
||||||
|
};
|
||||||
|
let saved = ctx.save_flow(&req.flow_id, &target).await.map_err(rpc_error)?;
|
||||||
|
Ok(SavedFlow { request_id: saved.request.id, name: saved.request.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn export_har(ctx: ProxyCtx, req: ExportHarRequest) -> Result<usize, RpcError> {
|
||||||
|
let flows = match req.ids {
|
||||||
|
Some(ids) => ids.iter().map(|id| flows::get(&ctx.db, id)).collect::<Result<Vec<_>, _>>(),
|
||||||
|
None => {
|
||||||
|
let filter = flows::FlowFilter { limit: None, ..req.filter.unwrap_or_default() };
|
||||||
|
flows::list(&ctx.db, &filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map_err(rpc_error)?;
|
||||||
|
let limit = settings(&ctx)?.capture_body_limit;
|
||||||
|
let har = har::export(&ctx.blobs, &flows, limit).await.map_err(rpc_error)?;
|
||||||
|
let path = PathBuf::from(req.path);
|
||||||
|
blocking(move || {
|
||||||
|
let file = std::fs::File::create(&path)
|
||||||
|
.map_err(|e| format!("Failed to create {}: {e}", path.display()))?;
|
||||||
|
let mut out = std::io::BufWriter::new(file);
|
||||||
|
serde_json::to_writer_pretty(&mut out, &har)
|
||||||
|
.map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
|
||||||
|
out.flush().map_err(|e| format!("Failed to write {}: {e}", path.display()))
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(flows.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_body(_ctx: &ProxyCtx, req: FormatBodyRequest) -> Result<String, RpcError> {
|
fn format_body(_ctx: &ProxyCtx, req: FormatBodyRequest) -> Result<String, RpcError> {
|
||||||
@@ -573,6 +1048,7 @@ async fn ca_trust(ctx: ProxyCtx, _req: CaTrustRequest) -> Result<CaStatus, RpcEr
|
|||||||
let ca = CaStore::load_or_create(&ctx.ca_dir())
|
let ca = CaStore::load_or_create(&ctx.ca_dir())
|
||||||
.map_err(|e| rpc_error(format!("Failed to load proxy CA: {e}")))?;
|
.map_err(|e| rpc_error(format!("Failed to load proxy CA: {e}")))?;
|
||||||
ca::trust(ca.cert_path()).await.map_err(rpc_error)?;
|
ca::trust(ca.cert_path()).await.map_err(rpc_error)?;
|
||||||
|
ctx.forget_passthrough();
|
||||||
ca::status(ca.cert_path()).await.map_err(rpc_error)
|
ca::status(ca.cert_path()).await.map_err(rpc_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,8 +1092,8 @@ fn list_actions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn list_flows(ctx: &ProxyCtx, req: flows::FlowFilter) -> Result<ListFlowsResponse, RpcError> {
|
fn list_flows(ctx: &ProxyCtx, req: flows::FlowFilter) -> Result<ListFlowsResponse, RpcError> {
|
||||||
let (flows, total) = flows::page(&ctx.db, &req).map_err(rpc_error)?;
|
let (flows, total, matching) = flows::page(&ctx.db, &req).map_err(rpc_error)?;
|
||||||
Ok(ListFlowsResponse { flows, total })
|
Ok(ListFlowsResponse { flows, total, matching })
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn clear_flows(ctx: ProxyCtx, _req: ClearFlowsRequest) -> Result<usize, RpcError> {
|
async fn clear_flows(ctx: ProxyCtx, _req: ClearFlowsRequest) -> Result<usize, RpcError> {
|
||||||
@@ -626,25 +1102,62 @@ async fn clear_flows(ctx: ProxyCtx, _req: ClearFlowsRequest) -> Result<usize, Rp
|
|||||||
|
|
||||||
// -- Event loop --
|
// -- Event loop --
|
||||||
|
|
||||||
|
/// Why the engine cannot be taken over from another app.
|
||||||
|
pub const ANOTHER_APP_OWNS: &str =
|
||||||
|
"Another Yaak Proxy window owns the engine. Quit it to use this one.";
|
||||||
|
|
||||||
|
/// How long taking over waits for the owner to let go of the engine.
|
||||||
|
const TAKE_OVER_WAIT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// The longest a new client connection waits for its app to be found before it is served.
|
||||||
|
const ACCEPT_ATTRIBUTION_WAIT: Duration = Duration::from_millis(20);
|
||||||
|
|
||||||
|
/// The longest a flow's end waits for its app to be found, which almost always it already is:
|
||||||
|
/// the lookup starts with the request and takes a few milliseconds.
|
||||||
|
const ATTRIBUTION_WAIT: Duration = Duration::from_millis(100);
|
||||||
|
|
||||||
/// A flow between its `RequestStart` and its end, with its bodies being written as they
|
/// A flow between its `RequestStart` and its end, with its bodies being written as they
|
||||||
/// stream.
|
/// stream.
|
||||||
struct InFlight {
|
struct InFlight {
|
||||||
flow: Flow,
|
flow: Flow,
|
||||||
|
/// The client connection it came on.
|
||||||
|
connection: u64,
|
||||||
request_body: Option<BodyChunkWriter>,
|
request_body: Option<BodyChunkWriter>,
|
||||||
response_body: Option<BodyChunkWriter>,
|
response_body: Option<BodyChunkWriter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_event_loop(
|
/// Where the event loop stores flows, and whether it does.
|
||||||
rx: std::sync::mpsc::Receiver<ProxyEvent>,
|
struct FlowSink {
|
||||||
db: ProxyQueryManager,
|
db: ProxyQueryManager,
|
||||||
blobs: BlobManager,
|
blobs: BlobManager,
|
||||||
events: RpcEventEmitter,
|
events: RpcEventEmitter,
|
||||||
) {
|
attribution: Attribution,
|
||||||
|
recording: Arc<Mutex<RecordingLog>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_event_loop(rx: std::sync::mpsc::Receiver<ProxyEvent>, sink: FlowSink) {
|
||||||
|
let FlowSink { db, blobs, events, attribution, recording } = sink;
|
||||||
let mut in_flight: HashMap<u64, InFlight> = HashMap::new();
|
let mut in_flight: HashMap<u64, InFlight> = HashMap::new();
|
||||||
|
|
||||||
while let Ok(event) = rx.recv() {
|
while let Ok(event) = rx.recv() {
|
||||||
match event {
|
match event {
|
||||||
ProxyEvent::RequestStart { id, started_at, method, url, http_version } => {
|
ProxyEvent::RequestStart {
|
||||||
|
id,
|
||||||
|
started_at,
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
http_version,
|
||||||
|
connection,
|
||||||
|
client_addr,
|
||||||
|
} => {
|
||||||
|
// Paused when it started: it passes through, and none of its events are kept.
|
||||||
|
// Judged by its start rather than now, as the loop can run behind.
|
||||||
|
if !recording.lock().map(|r| r.was_on_at(started_at)).unwrap_or(true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Begun as the connection was accepted; this covers one accepted before the
|
||||||
|
// engine's port was known.
|
||||||
|
attribution.begin(connection, client_addr);
|
||||||
let flow_id = Flow::generate_id();
|
let flow_id = Flow::generate_id();
|
||||||
let request_body = Flow::body_id(&flow_id, FlowBody::Request);
|
let request_body = Flow::body_id(&flow_id, FlowBody::Request);
|
||||||
let response_body = Flow::body_id(&flow_id, FlowBody::Response);
|
let response_body = Flow::body_id(&flow_id, FlowBody::Response);
|
||||||
@@ -660,6 +1173,7 @@ fn run_event_loop(
|
|||||||
id,
|
id,
|
||||||
InFlight {
|
InFlight {
|
||||||
flow,
|
flow,
|
||||||
|
connection,
|
||||||
request_body: Some(BodyChunkWriter::new(blobs.clone(), request_body)),
|
request_body: Some(BodyChunkWriter::new(blobs.clone(), request_body)),
|
||||||
response_body: Some(BodyChunkWriter::new(blobs.clone(), response_body)),
|
response_body: Some(BodyChunkWriter::new(blobs.clone(), response_body)),
|
||||||
},
|
},
|
||||||
@@ -710,14 +1224,14 @@ fn run_event_loop(
|
|||||||
f.flow.response_truncated = truncated;
|
f.flow.response_truncated = truncated;
|
||||||
f.flow.elapsed_ms = Some(clamp_ms(elapsed_ms));
|
f.flow.elapsed_ms = Some(clamp_ms(elapsed_ms));
|
||||||
f.flow.error = error;
|
f.flow.error = error;
|
||||||
finish_flow(&db, &events, f);
|
finish_flow(&db, &events, &attribution, f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ProxyEvent::Error { id, error, elapsed_ms } => {
|
ProxyEvent::Error { id, error, elapsed_ms } => {
|
||||||
if let Some(mut f) = in_flight.remove(&id) {
|
if let Some(mut f) = in_flight.remove(&id) {
|
||||||
f.flow.elapsed_ms = Some(clamp_ms(elapsed_ms));
|
f.flow.elapsed_ms = Some(clamp_ms(elapsed_ms));
|
||||||
f.flow.error = Some(error);
|
f.flow.error = Some(error);
|
||||||
finish_flow(&db, &events, f);
|
finish_flow(&db, &events, &attribution, f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -749,12 +1263,23 @@ fn finish_body(writer: Option<BodyChunkWriter>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn finish_flow(db: &ProxyQueryManager, events: &RpcEventEmitter, f: InFlight) {
|
fn finish_flow(
|
||||||
|
db: &ProxyQueryManager,
|
||||||
|
events: &RpcEventEmitter,
|
||||||
|
attribution: &Attribution,
|
||||||
|
f: InFlight,
|
||||||
|
) {
|
||||||
finish_body(f.request_body);
|
finish_body(f.request_body);
|
||||||
finish_body(f.response_body);
|
finish_body(f.response_body);
|
||||||
|
|
||||||
let mut flow = f.flow;
|
let mut flow = f.flow;
|
||||||
flow.content_type = find_header(&flow.response_headers, "content-type").map(String::from);
|
flow.content_type = find_header(&flow.response_headers, "content-type").map(String::from);
|
||||||
|
if let Some(app) = attribution.get(f.connection, ATTRIBUTION_WAIT) {
|
||||||
|
flow.client_pid = Some(app.pid);
|
||||||
|
flow.client_name = Some(app.name);
|
||||||
|
flow.client_bundle_id = app.bundle_id;
|
||||||
|
flow.client_path = Some(app.path);
|
||||||
|
}
|
||||||
let written = db.with_tx(|ctx| {
|
let written = db.with_tx(|ctx| {
|
||||||
let (saved, created) = ctx.upsert(&flow, &UpdateSource::Background)?;
|
let (saved, created) = ctx.upsert(&flow, &UpdateSource::Background)?;
|
||||||
// Sent while this holds the writer, before the commit, so a clear's event, sent after
|
// Sent while this holds the writer, before the commit, so a clear's event, sent after
|
||||||
@@ -777,18 +1302,26 @@ define_rpc! {
|
|||||||
commands {
|
commands {
|
||||||
execute_action(ActionInvocation) -> bool,
|
execute_action(ActionInvocation) -> bool,
|
||||||
format_body(FormatBodyRequest) -> String,
|
format_body(FormatBodyRequest) -> String,
|
||||||
get_proxy_state(GetProxyStateRequest) -> GetProxyStateResponse,
|
|
||||||
list_actions(ListActionsRequest) -> ListActionsResponse,
|
list_actions(ListActionsRequest) -> ListActionsResponse,
|
||||||
list_flows(flows::FlowFilter) -> ListFlowsResponse,
|
list_flows(flows::FlowFilter) -> ListFlowsResponse,
|
||||||
|
set_recording(SetRecordingRequest) -> bool,
|
||||||
|
set_system_proxy_at_launch(SetSystemProxyAtLaunchRequest) -> bool,
|
||||||
}
|
}
|
||||||
async_commands {
|
async_commands {
|
||||||
|
app_icon(AppIconRequest) -> Option<String>,
|
||||||
ca_status(CaStatusRequest) -> CaStatus,
|
ca_status(CaStatusRequest) -> CaStatus,
|
||||||
ca_trust(CaTrustRequest) -> CaStatus,
|
ca_trust(CaTrustRequest) -> CaStatus,
|
||||||
clear_flows(ClearFlowsRequest) -> usize,
|
clear_flows(ClearFlowsRequest) -> usize,
|
||||||
|
export_har(ExportHarRequest) -> usize,
|
||||||
filter_body(FilterBodyRequest) -> FilterBodyResponse,
|
filter_body(FilterBodyRequest) -> FilterBodyResponse,
|
||||||
get_flow_body(GetFlowBodyRequest) -> GetFlowBodyResponse,
|
get_flow_body(GetFlowBodyRequest) -> GetFlowBodyResponse,
|
||||||
|
get_proxy_state(GetProxyStateRequest) -> GetProxyStateResponse,
|
||||||
|
list_workspaces(ListWorkspacesRequest) -> ListWorkspacesResponse,
|
||||||
|
preview_save(PreviewSaveRequest) -> SavePreview,
|
||||||
|
save_flow(SaveFlowToYaakRequest) -> SavedFlow,
|
||||||
system_proxy_set(SystemProxySetRequest) -> SystemProxyStatus,
|
system_proxy_set(SystemProxySetRequest) -> SystemProxyStatus,
|
||||||
system_proxy_status(SystemProxyStatusRequest) -> SystemProxyStatus,
|
system_proxy_status(SystemProxyStatusRequest) -> SystemProxyStatus,
|
||||||
|
take_over(TakeOverRequest) -> bool,
|
||||||
}
|
}
|
||||||
events {
|
events {
|
||||||
flows_cleared(FlowsClearedPayload),
|
flows_cleared(FlowsClearedPayload),
|
||||||
@@ -801,6 +1334,24 @@ define_rpc! {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_request_is_recorded_by_the_state_when_it_started() {
|
||||||
|
let mut log = RecordingLog::default();
|
||||||
|
let before = SystemTime::now();
|
||||||
|
std::thread::sleep(Duration::from_millis(5));
|
||||||
|
assert!(log.set(false));
|
||||||
|
assert!(!log.set(false), "no change");
|
||||||
|
std::thread::sleep(Duration::from_millis(5));
|
||||||
|
let while_paused = SystemTime::now();
|
||||||
|
std::thread::sleep(Duration::from_millis(5));
|
||||||
|
assert!(log.set(true));
|
||||||
|
// Read after recording resumed, each is judged by when it started.
|
||||||
|
assert!(log.is_on());
|
||||||
|
assert!(log.was_on_at(before));
|
||||||
|
assert!(!log.was_on_at(while_paused));
|
||||||
|
assert!(log.was_on_at(SystemTime::now()));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn the_app_still_hears_why_a_body_failed_to_decode() {
|
async fn the_app_still_hears_why_a_body_failed_to_decode() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ pub struct Flow {
|
|||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
/// The id of the Yaak request this flow was last saved as.
|
/// The id of the Yaak request this flow was last saved as.
|
||||||
pub saved_request_id: Option<String>,
|
pub saved_request_id: Option<String>,
|
||||||
|
/// The process that opened the client connection, where the platform can tell (macOS).
|
||||||
|
pub client_pid: Option<i32>,
|
||||||
|
/// Its app's name, or its executable's outside an app: "Google Chrome", "curl".
|
||||||
|
pub client_name: Option<String>,
|
||||||
|
/// Its app's bundle id.
|
||||||
|
pub client_bundle_id: Option<String>,
|
||||||
|
/// Its app bundle's path, or its executable's outside an app.
|
||||||
|
pub client_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||||
@@ -148,6 +156,10 @@ impl UpsertModelInfo for Flow {
|
|||||||
(ElapsedMs, self.elapsed_ms.into()),
|
(ElapsedMs, self.elapsed_ms.into()),
|
||||||
(Error, self.error.into()),
|
(Error, self.error.into()),
|
||||||
(SavedRequestId, self.saved_request_id.into()),
|
(SavedRequestId, self.saved_request_id.into()),
|
||||||
|
(ClientPid, self.client_pid.into()),
|
||||||
|
(ClientName, self.client_name.into()),
|
||||||
|
(ClientBundleId, self.client_bundle_id.into()),
|
||||||
|
(ClientPath, self.client_path.into()),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +184,10 @@ impl UpsertModelInfo for Flow {
|
|||||||
FlowIden::ElapsedMs,
|
FlowIden::ElapsedMs,
|
||||||
FlowIden::Error,
|
FlowIden::Error,
|
||||||
FlowIden::SavedRequestId,
|
FlowIden::SavedRequestId,
|
||||||
|
FlowIden::ClientPid,
|
||||||
|
FlowIden::ClientName,
|
||||||
|
FlowIden::ClientBundleId,
|
||||||
|
FlowIden::ClientPath,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,11 +218,19 @@ impl UpsertModelInfo for Flow {
|
|||||||
elapsed_headers_ms: r.get("elapsed_headers_ms")?,
|
elapsed_headers_ms: r.get("elapsed_headers_ms")?,
|
||||||
elapsed_ms: r.get("elapsed_ms")?,
|
elapsed_ms: r.get("elapsed_ms")?,
|
||||||
error: r.get("error")?,
|
error: r.get("error")?,
|
||||||
// Absent from a database read before its migrations have run.
|
saved_request_id: added_column(r, "saved_request_id")?,
|
||||||
saved_request_id: match r.get("saved_request_id") {
|
client_pid: added_column(r, "client_pid")?,
|
||||||
Err(rusqlite::Error::InvalidColumnName(_)) => None,
|
client_name: added_column(r, "client_name")?,
|
||||||
v => v?,
|
client_bundle_id: added_column(r, "client_bundle_id")?,
|
||||||
},
|
client_path: added_column(r, "client_path")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A column added by a later migration, absent from a database read before it has run.
|
||||||
|
fn added_column<T: rusqlite::types::FromSql>(r: &Row, name: &str) -> rusqlite::Result<Option<T>> {
|
||||||
|
match r.get(name) {
|
||||||
|
Err(rusqlite::Error::InvalidColumnName(_)) => Ok(None),
|
||||||
|
v => v,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use crate::convert::{self, DroppedHeader};
|
|||||||
use crate::models::Flow;
|
use crate::models::Flow;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use ts_rs::TS;
|
||||||
use yaak_models::models::HttpRequest;
|
use yaak_models::models::HttpRequest;
|
||||||
use yaak_models::query_manager::QueryManager;
|
use yaak_models::query_manager::QueryManager;
|
||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
@@ -17,16 +18,18 @@ pub fn client_data_dir() -> PathBuf {
|
|||||||
yaak_common::data_dir::resolve_data_dir(app_id)
|
yaak_common::data_dir::resolve_data_dir(app_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
pub struct WorkspaceSummary {
|
pub struct WorkspaceSummary {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub folders: Vec<FolderSummary>,
|
pub folders: Vec<FolderSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
pub struct FolderSummary {
|
pub struct FolderSummary {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -39,7 +42,7 @@ pub struct FolderSummary {
|
|||||||
pub struct SaveFlowRequest {
|
pub struct SaveFlowRequest {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
pub folder_id: Option<String>,
|
pub folder_id: Option<String>,
|
||||||
/// Defaults to the URL's path.
|
/// None or blank leaves the request unnamed, and Yaak shows its URL.
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,11 +59,16 @@ pub struct ClientDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ClientDb {
|
impl ClientDb {
|
||||||
|
/// Yaak has run on this machine, so there is a database to save into.
|
||||||
|
pub fn exists(data_dir: &Path) -> bool {
|
||||||
|
data_dir.join("db.sqlite").exists()
|
||||||
|
}
|
||||||
|
|
||||||
/// Opens `db.sqlite` in Yaak's data directory `data_dir`. Fails when there is none, rather
|
/// Opens `db.sqlite` in Yaak's data directory `data_dir`. Fails when there is none, rather
|
||||||
/// than creating a database for a Yaak that has never run.
|
/// than creating a database for a Yaak that has never run.
|
||||||
pub fn open(data_dir: &Path) -> Result<Self, String> {
|
pub fn open(data_dir: &Path) -> Result<Self, String> {
|
||||||
let db_path = data_dir.join("db.sqlite");
|
let db_path = data_dir.join("db.sqlite");
|
||||||
if !db_path.exists() {
|
if !Self::exists(data_dir) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Yaak's database was not found at {}. Install Yaak and open it once.",
|
"Yaak's database was not found at {}. Install Yaak and open it once.",
|
||||||
db_path.display()
|
db_path.display()
|
||||||
|
|||||||
@@ -7,15 +7,22 @@ use yaak_proxy::DEFAULT_CAPTURE_BODY_LIMIT;
|
|||||||
#[serde(default, rename_all = "camelCase")]
|
#[serde(default, rename_all = "camelCase")]
|
||||||
pub struct ProxySettings {
|
pub struct ProxySettings {
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
/// Whether the system proxy points at the engine while it runs.
|
/// Point this Mac's web traffic at the engine as it starts. It is turned off again as the
|
||||||
pub system_proxy: bool,
|
/// engine stops, and can be turned on and off any time between. Read from the earlier
|
||||||
|
/// `systemProxy` too, so a saved choice to keep it off carries over.
|
||||||
|
#[serde(alias = "systemProxy")]
|
||||||
|
pub system_proxy_at_launch: bool,
|
||||||
/// Bytes of each request and response body stored per flow.
|
/// Bytes of each request and response body stored per flow.
|
||||||
pub capture_body_limit: usize,
|
pub capture_body_limit: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ProxySettings {
|
impl Default for ProxySettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self { port: 9090, system_proxy: false, capture_body_limit: DEFAULT_CAPTURE_BODY_LIMIT }
|
Self {
|
||||||
|
port: 9090,
|
||||||
|
system_proxy_at_launch: true,
|
||||||
|
capture_body_limit: DEFAULT_CAPTURE_BODY_LIMIT,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +53,7 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let settings = ProxySettings::load(&dir.path().join("settings.json")).unwrap();
|
let settings = ProxySettings::load(&dir.path().join("settings.json")).unwrap();
|
||||||
assert_eq!(settings.port, 9090);
|
assert_eq!(settings.port, 9090);
|
||||||
assert!(!settings.system_proxy);
|
assert!(settings.system_proxy_at_launch);
|
||||||
assert_eq!(settings.capture_body_limit, 10 * 1024 * 1024);
|
assert_eq!(settings.capture_body_limit, 10 * 1024 * 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +70,8 @@ mod tests {
|
|||||||
fn round_trip() {
|
fn round_trip() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let path = dir.path().join("settings.json");
|
let path = dir.path().join("settings.json");
|
||||||
let settings = ProxySettings { port: 8080, system_proxy: true, capture_body_limit: 1024 };
|
let settings =
|
||||||
|
ProxySettings { port: 8080, system_proxy_at_launch: false, capture_body_limit: 1024 };
|
||||||
settings.save(&path).unwrap();
|
settings.save(&path).unwrap();
|
||||||
assert_eq!(ProxySettings::load(&path).unwrap(), settings);
|
assert_eq!(ProxySettings::load(&path).unwrap(), settings);
|
||||||
|
|
||||||
@@ -71,10 +79,28 @@ mod tests {
|
|||||||
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
json,
|
json,
|
||||||
serde_json::json!({"port": 8080, "systemProxy": true, "captureBodyLimit": 1024})
|
serde_json::json!({"port": 8080, "systemProxyAtLaunch": false, "captureBodyLimit": 1024})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_earlier_system_proxy_key_carries_over() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("settings.json");
|
||||||
|
std::fs::write(&path, r#"{"port": 9090, "systemProxy": false}"#).unwrap();
|
||||||
|
assert!(!ProxySettings::load(&path).unwrap().system_proxy_at_launch);
|
||||||
|
std::fs::write(&path, r#"{"systemProxy": true}"#).unwrap();
|
||||||
|
assert!(ProxySettings::load(&path).unwrap().system_proxy_at_launch);
|
||||||
|
|
||||||
|
// Saved again under the new key only.
|
||||||
|
let settings = ProxySettings { system_proxy_at_launch: false, ..Default::default() };
|
||||||
|
settings.save(&path).unwrap();
|
||||||
|
let json: serde_json::Value =
|
||||||
|
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||||
|
assert_eq!(json["systemProxyAtLaunch"], false);
|
||||||
|
assert!(json.get("systemProxy").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_json_is_an_error() {
|
fn invalid_json_is_an_error() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use hyper::server::conn::http1;
|
|||||||
use hyper::service::service_fn;
|
use hyper::service::service_fn;
|
||||||
use hyper::{Request, Response};
|
use hyper::{Request, Response};
|
||||||
use hyper_util::rt::TokioIo;
|
use hyper_util::rt::TokioIo;
|
||||||
|
use serde_json::{Value, json};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use yaak_models::models::{Folder, Workspace};
|
use yaak_models::models::{Folder, Workspace};
|
||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
@@ -45,7 +46,7 @@ async fn spawn_upstream() -> SocketAddr {
|
|||||||
|
|
||||||
/// A context over `dir` whose engine picks a free port, saving into Yaak's data in `client`.
|
/// A context over `dir` whose engine picks a free port, saving into Yaak's data in `client`.
|
||||||
fn ctx(dir: &Path, client: &Path) -> ProxyCtx {
|
fn ctx(dir: &Path, client: &Path) -> ProxyCtx {
|
||||||
let settings = ProxySettings { port: 0, ..Default::default() };
|
let settings = ProxySettings { port: 0, system_proxy_at_launch: false, ..Default::default() };
|
||||||
settings.save(&dir.join("settings.json")).unwrap();
|
settings.save(&dir.join("settings.json")).unwrap();
|
||||||
let (events, _rx) = RpcEventEmitter::new();
|
let (events, _rx) = RpcEventEmitter::new();
|
||||||
ProxyCtx::new(dir, events).unwrap().with_client_data_dir(client)
|
ProxyCtx::new(dir, events).unwrap().with_client_data_dir(client)
|
||||||
@@ -138,6 +139,10 @@ async fn round_trip() {
|
|||||||
assert!(client.flows(&following).await.unwrap().is_empty());
|
assert!(client.flows(&following).await.unwrap().is_empty());
|
||||||
let filter = FlowFilter { status: Some("4xx".into()), ..Default::default() };
|
let filter = FlowFilter { status: Some("4xx".into()), ..Default::default() };
|
||||||
assert!(client.flows(&filter).await.unwrap().is_empty());
|
assert!(client.flows(&filter).await.unwrap().is_empty());
|
||||||
|
let search = FlowFilter { q: Some("CHARGES post 2xx".into()), ..Default::default() };
|
||||||
|
assert_eq!(client.flows(&search).await.unwrap().len(), 1);
|
||||||
|
let search = FlowFilter { q: Some("charges get".into()), ..Default::default() };
|
||||||
|
assert!(client.flows(&search).await.unwrap().is_empty());
|
||||||
|
|
||||||
let detail = client.flow(&flow.id, 8).await.unwrap();
|
let detail = client.flow(&flow.id, 8).await.unwrap();
|
||||||
assert_eq!(detail.flow.method, "POST");
|
assert_eq!(detail.flow.method, "POST");
|
||||||
@@ -363,6 +368,15 @@ async fn save_writes_the_request_into_yaak() {
|
|||||||
let flow = yaak_proxy_lib::flows::get(&ctx.db, &flow.id).unwrap();
|
let flow = yaak_proxy_lib::flows::get(&ctx.db, &flow.id).unwrap();
|
||||||
assert_eq!(flow.saved_request_id, Some(saved.request.id));
|
assert_eq!(flow.saved_request_id, Some(saved.request.id));
|
||||||
|
|
||||||
|
// Without a name, through the control API as the CLI saves, it stays unnamed, as Yaak
|
||||||
|
// leaves a new request, so Yaak shows its URL.
|
||||||
|
for name in [None, Some(" ".to_string())] {
|
||||||
|
let target = SaveFlowRequest { workspace_id: workspace.id.clone(), folder_id: None, name };
|
||||||
|
let saved = client.save(&flow.id, &target).await.unwrap();
|
||||||
|
let request = query_manager.connect().get_http_request(&saved.request.id).unwrap();
|
||||||
|
assert_eq!(request.name, "");
|
||||||
|
}
|
||||||
|
|
||||||
let wrong = SaveFlowRequest { workspace_id: "wk_missing".into(), ..Default::default() };
|
let wrong = SaveFlowRequest { workspace_id: "wk_missing".into(), ..Default::default() };
|
||||||
assert!(client.save(&flow.id, &wrong).await.unwrap_err().contains("No workspace"));
|
assert!(client.save(&flow.id, &wrong).await.unwrap_err().contains("No workspace"));
|
||||||
}
|
}
|
||||||
@@ -424,3 +438,326 @@ async fn start_is_refused_while_another_process_owns_the_engine() {
|
|||||||
daemon.kill().unwrap();
|
daemon.kill().unwrap();
|
||||||
daemon.wait().unwrap();
|
daemon.wait().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn rpc(ctx: &ProxyCtx, cmd: &str, payload: Value) -> Result<Value, String> {
|
||||||
|
yaak_proxy_lib::build_router().dispatch(cmd, payload, ctx).await.map_err(|e| e.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn the_app_takes_over_from_the_cli() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let client_dir = tempfile::tempdir().unwrap();
|
||||||
|
let cli = ctx(dir.path(), client_dir.path()).with_owner(Owner::Cli);
|
||||||
|
let mut server = ControlServer::start(cli.clone()).unwrap();
|
||||||
|
let stop_rx = server.take_stop_rx().unwrap();
|
||||||
|
// The daemon exits as soon as it hears the stop.
|
||||||
|
let daemon = std::thread::spawn(move || {
|
||||||
|
stop_rx.recv().unwrap();
|
||||||
|
drop(server);
|
||||||
|
});
|
||||||
|
cli.start().unwrap();
|
||||||
|
let cli_port = cli.port().unwrap();
|
||||||
|
|
||||||
|
// The app opens onto the CLI's engine and cannot start its own.
|
||||||
|
let app = ctx(dir.path(), client_dir.path());
|
||||||
|
assert!(app.serve_control_api().unwrap_err().contains("already running in the yaak CLI"));
|
||||||
|
let state = rpc(&app, "get_proxy_state", json!({})).await.unwrap();
|
||||||
|
assert_eq!(state["state"], "stopped");
|
||||||
|
assert_eq!(state["otherOwner"]["owner"], "cli");
|
||||||
|
assert_eq!(state["otherOwner"]["proxyPort"], cli_port);
|
||||||
|
assert_eq!(state["otherOwner"]["pid"], std::process::id());
|
||||||
|
|
||||||
|
assert_eq!(rpc(&app, "take_over", json!({})).await.unwrap(), json!(true));
|
||||||
|
daemon.join().unwrap();
|
||||||
|
assert_eq!(cli.port(), None);
|
||||||
|
assert!(app.port().is_some());
|
||||||
|
let owner = control::read(dir.path()).unwrap();
|
||||||
|
assert_eq!((owner.owner, owner.proxy_port), (Owner::App, app.port()));
|
||||||
|
let state = rpc(&app, "get_proxy_state", json!({})).await.unwrap();
|
||||||
|
assert_eq!((state["state"].as_str(), &state["otherOwner"]), (Some("running"), &Value::Null));
|
||||||
|
|
||||||
|
// Taking over with no one else holding the engine only starts it.
|
||||||
|
assert_eq!(rpc(&app, "take_over", json!({})).await.unwrap(), json!(true));
|
||||||
|
app.stop().unwrap();
|
||||||
|
app.stop_control_api();
|
||||||
|
assert!(control::live_owner(dir.path()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn the_save_dialog_reads_workspaces_previews_and_saves() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let client_dir = tempfile::tempdir().unwrap();
|
||||||
|
let ctx = ctx(dir.path(), client_dir.path());
|
||||||
|
ctx.start().unwrap();
|
||||||
|
let flow = capture_flow(&ctx).await;
|
||||||
|
|
||||||
|
// Before Yaak has ever run there is nothing to save into, and the dialog says so.
|
||||||
|
let listed = rpc(&ctx, "list_workspaces", json!({})).await.unwrap();
|
||||||
|
assert_eq!(listed, json!({ "yaakFound": false, "workspaces": [] }));
|
||||||
|
|
||||||
|
let (query_manager, _blobs, _rx) = yaak_models::init_standalone(
|
||||||
|
client_dir.path().join("db.sqlite"),
|
||||||
|
client_dir.path().join("blobs.sqlite"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let (workspace, folder) = query_manager
|
||||||
|
.with_tx(|tx| {
|
||||||
|
let workspace = tx.upsert_workspace(
|
||||||
|
&Workspace { name: "Payments".into(), ..Default::default() },
|
||||||
|
&UpdateSource::Background,
|
||||||
|
)?;
|
||||||
|
let folder = tx.upsert_folder(
|
||||||
|
&Folder {
|
||||||
|
name: "Stripe".into(),
|
||||||
|
workspace_id: workspace.id.clone(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
&UpdateSource::Background,
|
||||||
|
)?;
|
||||||
|
Ok::<_, yaak_models::error::Error>((workspace, folder))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let listed = rpc(&ctx, "list_workspaces", json!({})).await.unwrap();
|
||||||
|
assert_eq!(listed["yaakFound"], true);
|
||||||
|
assert_eq!(listed["workspaces"][0]["name"], "Payments");
|
||||||
|
assert_eq!(listed["workspaces"][0]["folders"][0]["id"], folder.id.as_str());
|
||||||
|
|
||||||
|
let preview = rpc(&ctx, "preview_save", json!({ "flowId": flow.id })).await.unwrap();
|
||||||
|
assert_eq!(preview["method"], "POST");
|
||||||
|
assert!(preview["requestUrl"].as_str().unwrap().ends_with("/v1/charges"), "{preview}");
|
||||||
|
assert_eq!(preview["bodyType"], "application/json");
|
||||||
|
assert_eq!(preview["body"], "JSON");
|
||||||
|
assert_eq!(preview["authorization"], true);
|
||||||
|
let dropped: Vec<&str> = preview["droppedHeaders"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|h| h["name"].as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert!(dropped.iter().any(|h| h.eq_ignore_ascii_case("host")), "{dropped:?}");
|
||||||
|
assert!(dropped.iter().any(|h| h.eq_ignore_ascii_case("content-length")), "{dropped:?}");
|
||||||
|
assert!(!dropped.iter().any(|h| h.eq_ignore_ascii_case("authorization")), "{dropped:?}");
|
||||||
|
assert_eq!(
|
||||||
|
preview["keptHeaders"].as_u64().unwrap() as usize + dropped.len(),
|
||||||
|
flow.request_headers.len()
|
||||||
|
);
|
||||||
|
assert!(preview["warnings"][0].as_str().unwrap().contains("Authorization"));
|
||||||
|
|
||||||
|
let saved = rpc(
|
||||||
|
&ctx,
|
||||||
|
"save_flow",
|
||||||
|
json!({
|
||||||
|
"flowId": flow.id,
|
||||||
|
"workspaceId": workspace.id,
|
||||||
|
"folderId": folder.id,
|
||||||
|
"name": "Create charge",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(saved["name"], "Create charge");
|
||||||
|
let request_id = saved["requestId"].as_str().unwrap();
|
||||||
|
let request = query_manager.connect().get_http_request(request_id).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(request.workspace_id.as_str(), request.folder_id.as_deref()),
|
||||||
|
(workspace.id.as_str(), Some(folder.id.as_str()))
|
||||||
|
);
|
||||||
|
let stored = ctx
|
||||||
|
.db
|
||||||
|
.with_conn(|db| db.find_one::<Flow>(yaak_proxy_lib::models::FlowIden::Id, &flow.id))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored.saved_request_id.as_deref(), Some(request_id));
|
||||||
|
|
||||||
|
// A workspace that is gone is an error the dialog can show.
|
||||||
|
let err = rpc(&ctx, "save_flow", json!({ "flowId": flow.id, "workspaceId": "wk_gone" }))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.contains("No workspace wk_gone"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn export_har_writes_the_flows_asked_for() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let client_dir = tempfile::tempdir().unwrap();
|
||||||
|
let ctx = ctx(dir.path(), client_dir.path());
|
||||||
|
ctx.start().unwrap();
|
||||||
|
let first = capture_flow(&ctx).await;
|
||||||
|
let second = capture_flow(&ctx).await;
|
||||||
|
let read =
|
||||||
|
|path: &Path| -> Value { serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap() };
|
||||||
|
|
||||||
|
let selected = dir.path().join("selected.har");
|
||||||
|
let written =
|
||||||
|
rpc(&ctx, "export_har", json!({ "path": selected, "ids": [first.id] })).await.unwrap();
|
||||||
|
assert_eq!(written, 1);
|
||||||
|
assert_eq!(read(&selected)["log"]["entries"].as_array().unwrap().len(), 1);
|
||||||
|
|
||||||
|
let searched = dir.path().join("searched.har");
|
||||||
|
let filter = json!({ "q": "charges", "limit": 1 });
|
||||||
|
let written =
|
||||||
|
rpc(&ctx, "export_har", json!({ "path": searched, "filter": filter })).await.unwrap();
|
||||||
|
// The whole search, not the page the list had loaded.
|
||||||
|
assert_eq!(written, 2);
|
||||||
|
let entries = read(&searched)["log"]["entries"].as_array().unwrap().len();
|
||||||
|
assert_eq!(entries, 2);
|
||||||
|
assert_ne!(first.id, second.id);
|
||||||
|
|
||||||
|
let none = dir.path().join("none.har");
|
||||||
|
let written =
|
||||||
|
rpc(&ctx, "export_har", json!({ "path": none, "filter": { "q": "nothing-matches" } }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(written, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// curl, through the engine, shows as the app behind its flow.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn flows_name_the_app_that_sent_them() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let client_dir = tempfile::tempdir().unwrap();
|
||||||
|
let ctx = ctx(dir.path(), client_dir.path());
|
||||||
|
ctx.start().unwrap();
|
||||||
|
let upstream = spawn_upstream().await;
|
||||||
|
let url = format!("http://{upstream}/attributed");
|
||||||
|
|
||||||
|
let mut curl = std::process::Command::new("/usr/bin/curl")
|
||||||
|
.args(["-s", "-o", "/dev/null", "--max-time", "10", "--noproxy", ""])
|
||||||
|
.args([
|
||||||
|
"-x",
|
||||||
|
&format!("http://127.0.0.1:{}", ctx.port().unwrap()),
|
||||||
|
&url,
|
||||||
|
])
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
let curl_pid = curl.id() as i32;
|
||||||
|
assert!(curl.wait().unwrap().success());
|
||||||
|
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
let flow = loop {
|
||||||
|
let flows = ctx.db.with_conn(|db| db.find_all::<Flow>().unwrap());
|
||||||
|
if let Some(flow) = flows.into_iter().find(|f| f.url == url) {
|
||||||
|
break flow;
|
||||||
|
}
|
||||||
|
assert!(Instant::now() < deadline, "no flow for {url}");
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
};
|
||||||
|
assert_eq!(flow.client_name.as_deref(), Some("curl"));
|
||||||
|
assert_eq!(flow.client_pid, Some(curl_pid));
|
||||||
|
assert_eq!(flow.client_bundle_id, None);
|
||||||
|
assert_eq!(flow.client_path.as_deref(), Some("/usr/bin/curl"));
|
||||||
|
|
||||||
|
// The search and the sidebar's Apps group find it by its path.
|
||||||
|
let filter = FlowFilter { app: Some(vec!["/usr/bin/curl".into()]), ..Default::default() };
|
||||||
|
assert_eq!(yaak_proxy_lib::flows::list(&ctx.db, &filter).unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends one GET through the engine, without waiting for a flow.
|
||||||
|
async fn send_through(ctx: &ProxyCtx, path: &str) -> String {
|
||||||
|
let upstream = spawn_upstream().await;
|
||||||
|
let url = format!("http://{upstream}{path}");
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.proxy(reqwest::Proxy::http(format!("http://127.0.0.1:{}", ctx.port().unwrap())).unwrap())
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let resp = client.get(&url).send().await.unwrap();
|
||||||
|
assert_eq!(resp.status(), 200, "traffic passes through either way");
|
||||||
|
url
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn pausing_stores_no_flows_and_resuming_stores_them_again() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let client_dir = tempfile::tempdir().unwrap();
|
||||||
|
let ctx = ctx(dir.path(), client_dir.path());
|
||||||
|
let _server = ControlServer::start(ctx.clone()).unwrap();
|
||||||
|
ctx.start().unwrap();
|
||||||
|
let client = ControlClient::new(control::read(dir.path()).unwrap());
|
||||||
|
assert!(client.status().await.unwrap().recording);
|
||||||
|
|
||||||
|
// Paused from the app.
|
||||||
|
assert_eq!(rpc(&ctx, "set_recording", json!({ "enabled": false })).await.unwrap(), false);
|
||||||
|
let state = rpc(&ctx, "get_proxy_state", json!({})).await.unwrap();
|
||||||
|
assert_eq!((state["state"].as_str(), &state["recording"]), (Some("running"), &json!(false)));
|
||||||
|
let paused = send_through(&ctx, "/while-paused").await;
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
let flows = ctx.db.with_conn(|db| db.find_all::<Flow>().unwrap());
|
||||||
|
assert!(flows.iter().all(|f| f.url != paused), "a flow was stored while paused");
|
||||||
|
assert!(ctx.blobs.connect().list_body_ids().unwrap().is_empty(), "a body was stored");
|
||||||
|
|
||||||
|
// Resumed through the control API, as the CLI would.
|
||||||
|
let status = client.set_recording(true).await.unwrap();
|
||||||
|
assert!(status.recording);
|
||||||
|
assert!(ctx.recording());
|
||||||
|
let flow = capture_flow(&ctx).await;
|
||||||
|
assert_eq!(flow.method, "POST");
|
||||||
|
// And the hotkey's toggle pauses again.
|
||||||
|
rpc(&ctx, "execute_action", json!({ "scope": "global", "action": "recording_toggle" }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!client.status().await.unwrap().recording);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn a_failed_start_is_kept_for_the_app_until_the_engine_starts() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let client_dir = tempfile::tempdir().unwrap();
|
||||||
|
let ctx = ctx(dir.path(), client_dir.path());
|
||||||
|
// Something else has the port.
|
||||||
|
let taken = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let port = taken.local_addr().unwrap().port();
|
||||||
|
let settings = ProxySettings { port, system_proxy_at_launch: false, ..Default::default() };
|
||||||
|
settings.save(&dir.path().join("settings.json")).unwrap();
|
||||||
|
|
||||||
|
assert!(ctx.start().is_err());
|
||||||
|
let state = rpc(&ctx, "get_proxy_state", json!({})).await.unwrap();
|
||||||
|
assert_eq!(state["state"], "stopped");
|
||||||
|
assert!(state["startError"].as_str().unwrap().contains(&port.to_string()), "{state}");
|
||||||
|
|
||||||
|
drop(taken);
|
||||||
|
ctx.start().unwrap();
|
||||||
|
let state = rpc(&ctx, "get_proxy_state", json!({})).await.unwrap();
|
||||||
|
assert_eq!((state["state"].as_str(), &state["startError"]), (Some("running"), &Value::Null));
|
||||||
|
ctx.stop().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn the_system_proxy_at_launch_setting_is_saved_and_routing_needs_an_engine() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let client_dir = tempfile::tempdir().unwrap();
|
||||||
|
let ctx = ctx(dir.path(), client_dir.path());
|
||||||
|
|
||||||
|
// Defaults on, like Proxyman.
|
||||||
|
assert!(ProxySettings::default().system_proxy_at_launch);
|
||||||
|
for enabled in [true, false] {
|
||||||
|
rpc(&ctx, "set_system_proxy_at_launch", json!({ "enabled": enabled })).await.unwrap();
|
||||||
|
assert_eq!(ctx.settings().unwrap().system_proxy_at_launch, enabled);
|
||||||
|
let state = rpc(&ctx, "get_proxy_state", json!({})).await.unwrap();
|
||||||
|
assert_eq!(state["systemProxyAtLaunch"], enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no engine there is nothing to point the Mac at.
|
||||||
|
let err = rpc(&ctx, "system_proxy_set", json!({ "enabled": true })).await.unwrap_err();
|
||||||
|
assert!(err.contains("not running"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn another_app_owning_the_engine_is_not_taken_over() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let client_dir = tempfile::tempdir().unwrap();
|
||||||
|
// Another app instance, whose engine a stop would halt without releasing its claim.
|
||||||
|
let other = ctx(dir.path(), client_dir.path());
|
||||||
|
let _server = ControlServer::start(other.clone()).unwrap();
|
||||||
|
other.start().unwrap();
|
||||||
|
|
||||||
|
let app = ctx(dir.path(), client_dir.path());
|
||||||
|
let state = rpc(&app, "get_proxy_state", json!({})).await.unwrap();
|
||||||
|
assert_eq!(state["otherOwner"]["owner"], "app");
|
||||||
|
let err = rpc(&app, "take_over", json!({})).await.unwrap_err();
|
||||||
|
assert_eq!(err, yaak_proxy_lib::ANOTHER_APP_OWNS);
|
||||||
|
// Refused before touching it: the other app keeps running.
|
||||||
|
assert!(other.port().is_some());
|
||||||
|
assert_eq!(app.port(), None);
|
||||||
|
other.stop().unwrap();
|
||||||
|
}
|
||||||
@@ -107,7 +107,7 @@ async fn wait_for_flow(ctx: &ProxyCtx, url: &str) -> Flow {
|
|||||||
async fn flow_is_stored_with_its_bodies() {
|
async fn flow_is_stored_with_its_bodies() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let settings =
|
let settings =
|
||||||
ProxySettings { port: 0, capture_body_limit: CAPTURE_LIMIT, ..Default::default() };
|
ProxySettings { port: 0, capture_body_limit: CAPTURE_LIMIT, system_proxy_at_launch: false };
|
||||||
settings.save(&dir.path().join("settings.json")).unwrap();
|
settings.save(&dir.path().join("settings.json")).unwrap();
|
||||||
|
|
||||||
let (events, _event_rx) = RpcEventEmitter::new();
|
let (events, _event_rx) = RpcEventEmitter::new();
|
||||||
@@ -175,7 +175,7 @@ async fn flow_is_stored_with_its_bodies() {
|
|||||||
async fn early_response_keeps_request_totals() {
|
async fn early_response_keeps_request_totals() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let settings =
|
let settings =
|
||||||
ProxySettings { port: 0, capture_body_limit: CAPTURE_LIMIT, ..Default::default() };
|
ProxySettings { port: 0, capture_body_limit: CAPTURE_LIMIT, system_proxy_at_launch: false };
|
||||||
settings.save(&dir.path().join("settings.json")).unwrap();
|
settings.save(&dir.path().join("settings.json")).unwrap();
|
||||||
|
|
||||||
let (events, _event_rx) = RpcEventEmitter::new();
|
let (events, _event_rx) = RpcEventEmitter::new();
|
||||||
@@ -208,7 +208,7 @@ async fn rpc(ctx: &ProxyCtx, cmd: &str, payload: Value) -> Result<Value, String>
|
|||||||
async fn body_rpcs() {
|
async fn body_rpcs() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let settings =
|
let settings =
|
||||||
ProxySettings { port: 0, capture_body_limit: CAPTURE_LIMIT, ..Default::default() };
|
ProxySettings { port: 0, capture_body_limit: CAPTURE_LIMIT, system_proxy_at_launch: false };
|
||||||
settings.save(&dir.path().join("settings.json")).unwrap();
|
settings.save(&dir.path().join("settings.json")).unwrap();
|
||||||
|
|
||||||
let (events, _event_rx) = RpcEventEmitter::new();
|
let (events, _event_rx) = RpcEventEmitter::new();
|
||||||
@@ -217,7 +217,18 @@ async fn body_rpcs() {
|
|||||||
let port = ctx.port().unwrap();
|
let port = ctx.port().unwrap();
|
||||||
|
|
||||||
let state = rpc(&ctx, "get_proxy_state", json!({})).await.unwrap();
|
let state = rpc(&ctx, "get_proxy_state", json!({})).await.unwrap();
|
||||||
assert_eq!(state, json!({"state": "running", "port": port}));
|
assert_eq!(
|
||||||
|
state,
|
||||||
|
json!({
|
||||||
|
"state": "running",
|
||||||
|
"port": port,
|
||||||
|
"configuredPort": 0,
|
||||||
|
"recording": true,
|
||||||
|
"systemProxyAtLaunch": false,
|
||||||
|
"startError": null,
|
||||||
|
"otherOwner": null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
let upstream = spawn_upstream().await;
|
let upstream = spawn_upstream().await;
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ tauri-build = { version = "2.6.1", features = [] }
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["wry"]
|
default = ["wry"]
|
||||||
wry = ["tauri/wry", "tauri/x11", "tauri/dbus"]
|
wry = ["tauri/wry", "tauri/x11", "tauri/dbus", "tauri-plugin-dialog/gtk3"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tauri = { workspace = true, features = ["devtools"] }
|
tauri = { workspace = true, features = ["devtools"] }
|
||||||
|
tauri-plugin-dialog = { workspace = true }
|
||||||
|
tauri-plugin-opener = "2.5.4"
|
||||||
tauri-plugin-os = "2.3.2"
|
tauri-plugin-os = "2.3.2"
|
||||||
yaak-mac-window = { workspace = true }
|
yaak-mac-window = { workspace = true }
|
||||||
yaak-proxy-lib = { workspace = true }
|
yaak-proxy-lib = { workspace = true }
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
"windows": ["*"],
|
"windows": ["*"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
"dialog:allow-save",
|
||||||
|
"opener:allow-default-urls",
|
||||||
|
"opener:allow-open-url",
|
||||||
"os:allow-os-type",
|
"os:allow-os-type",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-is-fullscreen",
|
"core:window:allow-is-fullscreen",
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
use std::sync::Mutex;
|
|
||||||
use tauri::Runtime;
|
use tauri::Runtime;
|
||||||
use tauri::{Emitter, Manager, RunEvent, State, WebviewWindow};
|
use tauri::{Emitter, Manager, RunEvent, State, WebviewWindow};
|
||||||
use yaak_proxy_lib::ProxyCtx;
|
use yaak_proxy_lib::ProxyCtx;
|
||||||
use yaak_proxy_lib::control::{self, ControlServer};
|
|
||||||
use yaak_rpc::{RpcEventEmitter, RpcRouter};
|
use yaak_rpc::{RpcEventEmitter, RpcRouter};
|
||||||
use yaak_window::window::CreateWindowConfig;
|
use yaak_window::window::CreateWindowConfig;
|
||||||
|
|
||||||
@@ -50,40 +48,6 @@ fn setup_window_menu<R: Runtime>(win: &WebviewWindow<R>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The control API, served while this app owns the engine.
|
|
||||||
struct Control(Mutex<Option<ControlServer>>);
|
|
||||||
|
|
||||||
/// Claims the engine and serves the control API, unless a live owner (the CLI daemon) has it.
|
|
||||||
fn host_control_api(ctx: &ProxyCtx) -> Option<ControlServer> {
|
|
||||||
// Read first: a crashed owner's file names the port its system proxy may still point at.
|
|
||||||
let previous = control::read(ctx.data_dir());
|
|
||||||
if let Some(owner) = control::live_owner(ctx.data_dir()) {
|
|
||||||
// Starting the engine here is refused too, until the owner stops.
|
|
||||||
warn!(
|
|
||||||
"The proxy engine is owned by {:?} (pid {}), so this app does not serve the control API",
|
|
||||||
owner.owner, owner.pid
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let server = match ControlServer::start(ctx.clone()) {
|
|
||||||
Ok(server) => server,
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to start the proxy control API: {e}");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut ports: Vec<u16> = previous.and_then(|p| p.proxy_port).into_iter().collect();
|
|
||||||
ports.extend(ctx.settings().ok().map(|s| s.port));
|
|
||||||
let ctx = ctx.clone();
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
if let Err(e) = ctx.reset_stale_system_proxy(&ports) {
|
|
||||||
warn!("Failed to reset a stale system proxy: {e}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Some(server)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn rpc(
|
async fn rpc(
|
||||||
router: State<'_, RpcRouter<ProxyCtx>>,
|
router: State<'_, RpcRouter<ProxyCtx>>,
|
||||||
@@ -97,6 +61,8 @@ async fn rpc(
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_os::init())
|
.plugin(tauri_plugin_os::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(yaak_mac_window::init())
|
.plugin(yaak_mac_window::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let data_dir = app.path().app_data_dir().expect("no app data dir");
|
let data_dir = app.path().app_data_dir().expect("no app data dir");
|
||||||
@@ -104,7 +70,20 @@ pub fn run() {
|
|||||||
|
|
||||||
let (emitter, event_rx) = RpcEventEmitter::new();
|
let (emitter, event_rx) = RpcEventEmitter::new();
|
||||||
let ctx = ProxyCtx::new(&data_dir, emitter)?;
|
let ctx = ProxyCtx::new(&data_dir, emitter)?;
|
||||||
app.manage(Control(Mutex::new(host_control_api(&ctx))));
|
// Not while the CLI daemon owns the engine: the window offers to take it over.
|
||||||
|
match ctx.serve_control_api() {
|
||||||
|
// The engine runs for as long as the app does. A failure, such as the port
|
||||||
|
// being taken, is kept for the window to show.
|
||||||
|
Ok(()) => {
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Err(e) = ctx.start() {
|
||||||
|
warn!("Failed to start the proxy: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => warn!("Not serving the proxy control API: {e}"),
|
||||||
|
}
|
||||||
app.manage(ctx);
|
app.manage(ctx);
|
||||||
app.manage(yaak_proxy_lib::build_router());
|
app.manage(yaak_proxy_lib::build_router());
|
||||||
|
|
||||||
@@ -126,10 +105,9 @@ pub fn run() {
|
|||||||
.run(|app_handle, event| {
|
.run(|app_handle, event| {
|
||||||
if let RunEvent::Exit = event {
|
if let RunEvent::Exit = event {
|
||||||
// Never leave the Mac pointed at a proxy that is gone.
|
// Never leave the Mac pointed at a proxy that is gone.
|
||||||
app_handle.state::<ProxyCtx>().shutdown();
|
let ctx = app_handle.state::<ProxyCtx>();
|
||||||
if let Ok(mut control) = app_handle.state::<Control>().0.lock() {
|
ctx.shutdown();
|
||||||
control.take();
|
ctx.stop_control_api();
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let RunEvent::Ready = event {
|
if let RunEvent::Ready = event {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use hyper::service::service_fn;
|
|||||||
use hyper_util::rt::TokioIo;
|
use hyper_util::rt::TokioIo;
|
||||||
use tokio::io::{AsyncRead, AsyncWrite};
|
use tokio::io::{AsyncRead, AsyncWrite};
|
||||||
|
|
||||||
use crate::Engine;
|
|
||||||
use crate::request::handle_request;
|
use crate::request::handle_request;
|
||||||
|
use crate::{ClientConn, Engine};
|
||||||
|
|
||||||
pub(crate) async fn serve_http1<S, F, Fut>(io: S, handler: F) -> Result<(), hyper::Error>
|
pub(crate) async fn serve_http1<S, F, Fut>(io: S, handler: F) -> Result<(), hyper::Error>
|
||||||
where
|
where
|
||||||
@@ -26,7 +26,8 @@ where
|
|||||||
|
|
||||||
pub(crate) async fn handle_connection(
|
pub(crate) async fn handle_connection(
|
||||||
stream: tokio::net::TcpStream,
|
stream: tokio::net::TcpStream,
|
||||||
|
client: ClientConn,
|
||||||
engine: Arc<Engine>,
|
engine: Arc<Engine>,
|
||||||
) -> Result<(), hyper::Error> {
|
) -> Result<(), hyper::Error> {
|
||||||
serve_http1(stream, move |req| handle_request(req, engine.clone())).await
|
serve_http1(stream, move |req| handle_request(req, client, engine.clone())).await
|
||||||
}
|
}
|
||||||
@@ -42,8 +42,15 @@ pub struct ProxyConfig {
|
|||||||
pub capture_body_limit: usize,
|
pub capture_body_limit: usize,
|
||||||
/// Roots trusted for upstream TLS in addition to the platform's.
|
/// Roots trusted for upstream TLS in addition to the platform's.
|
||||||
pub upstream_root_certs: Vec<CertificateDer<'static>>,
|
pub upstream_root_certs: Vec<CertificateDer<'static>>,
|
||||||
|
/// Runs on a blocking thread as each client connection is accepted, with the connection's
|
||||||
|
/// number and the client's address, before anything is read from it, so the client is
|
||||||
|
/// still there to be looked up. The connection waits for it.
|
||||||
|
pub on_accept: Option<AcceptHook>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// See [`ProxyConfig::on_accept`].
|
||||||
|
pub type AcceptHook = Arc<dyn Fn(u64, SocketAddr) + Send + Sync>;
|
||||||
|
|
||||||
impl ProxyConfig {
|
impl ProxyConfig {
|
||||||
pub fn new(port: u16, ca: Arc<CaStore>) -> Self {
|
pub fn new(port: u16, ca: Arc<CaStore>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -51,6 +58,7 @@ impl ProxyConfig {
|
|||||||
ca,
|
ca,
|
||||||
capture_body_limit: DEFAULT_CAPTURE_BODY_LIMIT,
|
capture_body_limit: DEFAULT_CAPTURE_BODY_LIMIT,
|
||||||
upstream_root_certs: Vec::new(),
|
upstream_root_certs: Vec::new(),
|
||||||
|
on_accept: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,6 +77,10 @@ pub enum ProxyEvent {
|
|||||||
url: String,
|
url: String,
|
||||||
/// Version spoken by the client.
|
/// Version spoken by the client.
|
||||||
http_version: String,
|
http_version: String,
|
||||||
|
/// The client connection the request came on, numbered from 1 for each run.
|
||||||
|
connection: u64,
|
||||||
|
/// The client's end of that connection, to find the app that opened it.
|
||||||
|
client_addr: SocketAddr,
|
||||||
},
|
},
|
||||||
RequestHeader {
|
RequestHeader {
|
||||||
id: u64,
|
id: u64,
|
||||||
@@ -149,6 +161,13 @@ impl ProxyHandle {
|
|||||||
self.event_rx.take()
|
self.event_rx.take()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forgets the hosts passed through after clients rejected the CA, so they are intercepted
|
||||||
|
/// again, as once the CA is trusted.
|
||||||
|
pub fn forget_passthrough(&self) {
|
||||||
|
self.engine.passthrough.lock().unwrap().clear();
|
||||||
|
self.engine.handshake_failures.lock().unwrap().clear();
|
||||||
|
}
|
||||||
|
|
||||||
/// Handshake failures counted toward passing `addr` (`host:port`) through, and whether it
|
/// Handshake failures counted toward passing `addr` (`host:port`) through, and whether it
|
||||||
/// already is. For tests that must wait for the engine to record a failure.
|
/// already is. For tests that must wait for the engine to record a failure.
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
@@ -169,11 +188,19 @@ impl Drop for ProxyHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The client connection a request came on.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub(crate) struct ClientConn {
|
||||||
|
connection: u64,
|
||||||
|
addr: SocketAddr,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct Engine {
|
pub(crate) struct Engine {
|
||||||
ca: Arc<CaStore>,
|
ca: Arc<CaStore>,
|
||||||
capture_body_limit: usize,
|
capture_body_limit: usize,
|
||||||
events: std_mpsc::Sender<ProxyEvent>,
|
events: std_mpsc::Sender<ProxyEvent>,
|
||||||
next_id: AtomicU64,
|
next_id: AtomicU64,
|
||||||
|
next_connection: AtomicU64,
|
||||||
http_client: Client<HttpConnector, BoxBody>,
|
http_client: Client<HttpConnector, BoxBody>,
|
||||||
upstream_tls: Arc<ClientConfig>,
|
upstream_tls: Arc<ClientConfig>,
|
||||||
upstream_tls_h1: Arc<ClientConfig>,
|
upstream_tls_h1: Arc<ClientConfig>,
|
||||||
@@ -251,6 +278,7 @@ pub fn start_proxy(config: ProxyConfig) -> Result<ProxyHandle, String> {
|
|||||||
capture_body_limit: config.capture_body_limit,
|
capture_body_limit: config.capture_body_limit,
|
||||||
events: event_tx,
|
events: event_tx,
|
||||||
next_id: AtomicU64::new(1),
|
next_id: AtomicU64::new(1),
|
||||||
|
next_connection: AtomicU64::new(1),
|
||||||
http_client: Client::builder(TokioExecutor::new()).build_http(),
|
http_client: Client::builder(TokioExecutor::new()).build_http(),
|
||||||
upstream_tls: upstream_tls_config(&roots, &[b"h2", b"http/1.1"])?,
|
upstream_tls: upstream_tls_config(&roots, &[b"h2", b"http/1.1"])?,
|
||||||
upstream_tls_h1: upstream_tls_config(&roots, &[b"http/1.1"])?,
|
upstream_tls_h1: upstream_tls_config(&roots, &[b"http/1.1"])?,
|
||||||
@@ -260,6 +288,7 @@ pub fn start_proxy(config: ProxyConfig) -> Result<ProxyHandle, String> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let port = config.port;
|
let port = config.port;
|
||||||
|
let on_accept = config.on_accept;
|
||||||
let handle_engine = engine.clone();
|
let handle_engine = engine.clone();
|
||||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
||||||
let (ready_tx, ready_rx) = std_mpsc::channel();
|
let (ready_tx, ready_rx) = std_mpsc::channel();
|
||||||
@@ -289,10 +318,19 @@ pub fn start_proxy(config: ProxyConfig) -> Result<ProxyHandle, String> {
|
|||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
result = listener.accept() => match result {
|
result = listener.accept() => match result {
|
||||||
Ok((stream, _)) => {
|
Ok((stream, addr)) => {
|
||||||
let engine = engine.clone();
|
let engine = engine.clone();
|
||||||
|
let client = ClientConn {
|
||||||
|
connection: engine.next_connection.fetch_add(1, Ordering::Relaxed),
|
||||||
|
addr,
|
||||||
|
};
|
||||||
|
let on_accept = on_accept.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = handle_connection(stream, engine).await {
|
if let Some(hook) = on_accept {
|
||||||
|
let (connection, addr) = (client.connection, client.addr);
|
||||||
|
let _ = tokio::task::spawn_blocking(move || hook(connection, addr)).await;
|
||||||
|
}
|
||||||
|
if let Err(e) = handle_connection(stream, client, engine).await {
|
||||||
log::debug!("Client connection error: {e}");
|
log::debug!("Client connection error: {e}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use tokio_rustls::{LazyConfigAcceptor, TlsConnector};
|
|||||||
use crate::body::{CaptureBody, Direction};
|
use crate::body::{CaptureBody, Direction};
|
||||||
use crate::connection::serve_http1;
|
use crate::connection::serve_http1;
|
||||||
use crate::tunnel::{Rewind, splice, tunnel};
|
use crate::tunnel::{Rewind, splice, tunnel};
|
||||||
use crate::{Engine, ProxyEvent};
|
use crate::{ClientConn, Engine, ProxyEvent};
|
||||||
|
|
||||||
pub(crate) type BoxBody = http_body_util::combinators::BoxBody<Bytes, hyper::Error>;
|
pub(crate) type BoxBody = http_body_util::combinators::BoxBody<Bytes, hyper::Error>;
|
||||||
type BoxError = Box<dyn Error + Send + Sync>;
|
type BoxError = Box<dyn Error + Send + Sync>;
|
||||||
@@ -67,7 +67,12 @@ struct Flow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Flow {
|
impl Flow {
|
||||||
fn begin(engine: Arc<Engine>, req: &Request<Incoming>, url: String) -> Self {
|
fn begin(
|
||||||
|
engine: Arc<Engine>,
|
||||||
|
client: ClientConn,
|
||||||
|
req: &Request<Incoming>,
|
||||||
|
url: String,
|
||||||
|
) -> Self {
|
||||||
let flow = Self { id: engine.next_flow_id(), start: Instant::now(), engine };
|
let flow = Self { id: engine.next_flow_id(), start: Instant::now(), engine };
|
||||||
flow.engine.emit(ProxyEvent::RequestStart {
|
flow.engine.emit(ProxyEvent::RequestStart {
|
||||||
id: flow.id,
|
id: flow.id,
|
||||||
@@ -75,6 +80,8 @@ impl Flow {
|
|||||||
method: req.method().to_string(),
|
method: req.method().to_string(),
|
||||||
url,
|
url,
|
||||||
http_version: version_str(req.version()),
|
http_version: version_str(req.version()),
|
||||||
|
connection: client.connection,
|
||||||
|
client_addr: client.addr,
|
||||||
});
|
});
|
||||||
for (name, value) in req.headers() {
|
for (name, value) in req.headers() {
|
||||||
flow.engine.emit(ProxyEvent::RequestHeader {
|
flow.engine.emit(ProxyEvent::RequestHeader {
|
||||||
@@ -147,16 +154,17 @@ fn bad_gateway(body: String) -> Response<BoxBody> {
|
|||||||
|
|
||||||
pub(crate) async fn handle_request(
|
pub(crate) async fn handle_request(
|
||||||
mut req: Request<Incoming>,
|
mut req: Request<Incoming>,
|
||||||
|
client: ClientConn,
|
||||||
engine: Arc<Engine>,
|
engine: Arc<Engine>,
|
||||||
) -> Result<Response<BoxBody>, Infallible> {
|
) -> Result<Response<BoxBody>, Infallible> {
|
||||||
if req.method() == Method::CONNECT {
|
if req.method() == Method::CONNECT {
|
||||||
return Ok(handle_connect(req, engine));
|
return Ok(handle_connect(req, client, engine));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some clients send a bare path (`POST /api`) as they would to the server itself.
|
// Some clients send a bare path (`POST /api`) as they would to the server itself.
|
||||||
let from_host = req.uri().authority().is_none();
|
let from_host = req.uri().authority().is_none();
|
||||||
let rejected = if from_host { url_from_host(&mut req, &engine).err() } else { None };
|
let rejected = if from_host { url_from_host(&mut req, &engine).err() } else { None };
|
||||||
let flow = Flow::begin(engine.clone(), &req, req.uri().to_string());
|
let flow = Flow::begin(engine.clone(), client, &req, req.uri().to_string());
|
||||||
if let Some((status, error)) = rejected {
|
if let Some((status, error)) = rejected {
|
||||||
return Ok(flow.reject(status, error));
|
return Ok(flow.reject(status, error));
|
||||||
}
|
}
|
||||||
@@ -220,7 +228,11 @@ struct Target {
|
|||||||
addr: String,
|
addr: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_connect(req: Request<Incoming>, engine: Arc<Engine>) -> Response<BoxBody> {
|
fn handle_connect(
|
||||||
|
req: Request<Incoming>,
|
||||||
|
client: ClientConn,
|
||||||
|
engine: Arc<Engine>,
|
||||||
|
) -> Response<BoxBody> {
|
||||||
let Some(authority) = req.uri().authority().cloned() else {
|
let Some(authority) = req.uri().authority().cloned() else {
|
||||||
return Response::builder()
|
return Response::builder()
|
||||||
.status(StatusCode::BAD_REQUEST)
|
.status(StatusCode::BAD_REQUEST)
|
||||||
@@ -240,7 +252,7 @@ fn handle_connect(req: Request<Incoming>, engine: Arc<Engine>) -> Response<BoxBo
|
|||||||
authority: if port == 443 { host.clone() } else { format!("{host}:{port}") },
|
authority: if port == 443 { host.clone() } else { format!("{host}:{port}") },
|
||||||
addr: format!("{host}:{port}"),
|
addr: format!("{host}:{port}"),
|
||||||
};
|
};
|
||||||
if let Err(e) = intercept(upgraded, target, engine).await {
|
if let Err(e) = intercept(upgraded, target, client, engine).await {
|
||||||
debug!("Tunnel to {host}:{port} ended: {}", error_chain(e.as_ref()));
|
debug!("Tunnel to {host}:{port} ended: {}", error_chain(e.as_ref()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -248,7 +260,12 @@ fn handle_connect(req: Request<Incoming>, engine: Arc<Engine>) -> Response<BoxBo
|
|||||||
Response::new(text_body(Bytes::new()))
|
Response::new(text_body(Bytes::new()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn intercept<S>(mut client: S, target: Target, engine: Arc<Engine>) -> Result<(), BoxError>
|
async fn intercept<S>(
|
||||||
|
mut client: S,
|
||||||
|
target: Target,
|
||||||
|
conn: ClientConn,
|
||||||
|
engine: Arc<Engine>,
|
||||||
|
) -> Result<(), BoxError>
|
||||||
where
|
where
|
||||||
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||||
{
|
{
|
||||||
@@ -308,7 +325,7 @@ where
|
|||||||
|
|
||||||
let tls = Rewind::new(Bytes::from(buf), tls);
|
let tls = Rewind::new(Bytes::from(buf), tls);
|
||||||
let target = Arc::new(Target { server_name, ..target });
|
let target = Arc::new(Target { server_name, ..target });
|
||||||
serve_http1(tls, move |req| forward_https(req, target.clone(), engine.clone())).await?;
|
serve_http1(tls, move |req| forward_https(req, target.clone(), conn, engine.clone())).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,11 +412,12 @@ async fn connect_upstream(
|
|||||||
async fn forward_https(
|
async fn forward_https(
|
||||||
mut req: Request<Incoming>,
|
mut req: Request<Incoming>,
|
||||||
target: Arc<Target>,
|
target: Arc<Target>,
|
||||||
|
client: ClientConn,
|
||||||
engine: Arc<Engine>,
|
engine: Arc<Engine>,
|
||||||
) -> Result<Response<BoxBody>, Infallible> {
|
) -> Result<Response<BoxBody>, Infallible> {
|
||||||
let path = req.uri().path_and_query().map(|pq| pq.to_string()).unwrap_or_else(|| "/".into());
|
let path = req.uri().path_and_query().map(|pq| pq.to_string()).unwrap_or_else(|| "/".into());
|
||||||
let url = format!("https://{}{path}", target.authority);
|
let url = format!("https://{}{path}", target.authority);
|
||||||
let flow = Flow::begin(engine.clone(), &req, url.clone());
|
let flow = Flow::begin(engine.clone(), client, &req, url.clone());
|
||||||
|
|
||||||
let client_upgrade: Option<OnUpgrade> =
|
let client_upgrade: Option<OnUpgrade> =
|
||||||
req.headers().contains_key(UPGRADE).then(|| hyper::upgrade::on(&mut req));
|
req.headers().contains_key(UPGRADE).then(|| hyper::upgrade::on(&mut req));
|
||||||
|
|||||||
@@ -352,12 +352,15 @@ async fn https_get() {
|
|||||||
assert_eq!(resp.text().await.unwrap(), "hello");
|
assert_eq!(resp.text().await.unwrap(), "hello");
|
||||||
|
|
||||||
let events = h.flow_events();
|
let events = h.flow_events();
|
||||||
let (method, req_url, req_version, started_at) = find!(events,
|
let (method, req_url, req_version, started_at, connection, client_addr) = find!(events,
|
||||||
ProxyEvent::RequestStart { method, url, http_version, started_at, .. } =>
|
ProxyEvent::RequestStart { method, url, http_version, started_at, connection, client_addr, .. } =>
|
||||||
(method.clone(), url.clone(), http_version.clone(), *started_at));
|
(method.clone(), url.clone(), http_version.clone(), *started_at, *connection, *client_addr));
|
||||||
assert_eq!(method, "GET");
|
assert_eq!(method, "GET");
|
||||||
assert_eq!(req_url, url);
|
assert_eq!(req_url, url);
|
||||||
assert_eq!(req_version, "HTTP/1.1");
|
assert_eq!(req_version, "HTTP/1.1");
|
||||||
|
// The request inside the tunnel keeps the connection the CONNECT came on.
|
||||||
|
assert_eq!(connection, 1);
|
||||||
|
assert!(client_addr.ip().is_loopback() && client_addr.port() != h.proxy.port, "{client_addr}");
|
||||||
assert!(SystemTime::now().duration_since(started_at).unwrap() < Duration::from_secs(30));
|
assert!(SystemTime::now().duration_since(started_at).unwrap() < Duration::from_secs(30));
|
||||||
|
|
||||||
let (status, res_version, remote_addr, headers_ms) = find!(events,
|
let (status, res_version, remote_addr, headers_ms) = find!(events,
|
||||||
@@ -635,6 +638,10 @@ async fn hang_up_after_the_handshake_passes_through() {
|
|||||||
.connect(name, h.connect_tunnel(port).await)
|
.connect(name, h.connect_tunnel(port).await)
|
||||||
.await
|
.await
|
||||||
.expect("two hang-ups after the handshake should pass the host through");
|
.expect("two hang-ups after the handshake should pass the host through");
|
||||||
|
|
||||||
|
// Once the CA is trusted the host is intercepted again.
|
||||||
|
h.proxy.forget_passthrough();
|
||||||
|
assert_eq!(h.proxy.handshake_state(&addr), (0, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
|||||||
Generated
+2
@@ -248,6 +248,8 @@
|
|||||||
"@tanstack/react-query": "^5.104.2",
|
"@tanstack/react-query": "^5.104.2",
|
||||||
"@tanstack/react-virtual": "^3.14.17",
|
"@tanstack/react-virtual": "^3.14.17",
|
||||||
"@tauri-apps/api": "^2.12.3",
|
"@tauri-apps/api": "^2.12.3",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.8.2",
|
||||||
|
"@tauri-apps/plugin-opener": "^2.7.1",
|
||||||
"@tauri-apps/plugin-os": "^2.4.0",
|
"@tauri-apps/plugin-os": "^2.4.0",
|
||||||
"@yaakapp-internal/proxy-lib": "^1.0.0",
|
"@yaakapp-internal/proxy-lib": "^1.0.0",
|
||||||
"@yaakapp-internal/theme": "^1.0.0",
|
"@yaakapp-internal/theme": "^1.0.0",
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./editor": "./src/editor.ts"
|
"./editor": "./src/editor.ts",
|
||||||
|
"./resolvedModelName": "./src/lib/resolvedModelName.ts",
|
||||||
|
"./templateTags": "./src/lib/templateTags.ts",
|
||||||
|
"./toast": "./src/lib/toast.ts"
|
||||||
},
|
},
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
"*.css"
|
"*.css"
|
||||||
|
|||||||
@@ -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,
|
ArrowUpFromLineIcon,
|
||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
BadgeCheckIcon,
|
BadgeCheckIcon,
|
||||||
|
BookmarkIcon,
|
||||||
BookOpenText,
|
BookOpenText,
|
||||||
BoxIcon,
|
BoxIcon,
|
||||||
CakeIcon,
|
CakeIcon,
|
||||||
@@ -166,6 +167,7 @@ const icons = {
|
|||||||
arrow_up_from_dot: ArrowUpFromDotIcon,
|
arrow_up_from_dot: ArrowUpFromDotIcon,
|
||||||
arrow_up_from_line: ArrowUpFromLineIcon,
|
arrow_up_from_line: ArrowUpFromLineIcon,
|
||||||
badge_check: BadgeCheckIcon,
|
badge_check: BadgeCheckIcon,
|
||||||
|
bookmark: BookmarkIcon,
|
||||||
book_open_text: BookOpenText,
|
book_open_text: BookOpenText,
|
||||||
box: BoxIcon,
|
box: BoxIcon,
|
||||||
cake: CakeIcon,
|
cake: CakeIcon,
|
||||||
|
|||||||
@@ -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 { Banner } from "./components/Banner";
|
||||||
export type { ButtonProps } from "./components/Button";
|
export type { ButtonProps } from "./components/Button";
|
||||||
export { Button } from "./components/Button";
|
export { Button } from "./components/Button";
|
||||||
|
export type { CheckboxProps } from "./components/Checkbox";
|
||||||
|
export { Checkbox } from "./components/Checkbox";
|
||||||
export type { CopyIconButtonProps } from "./components/CopyIconButton";
|
export type { CopyIconButtonProps } from "./components/CopyIconButton";
|
||||||
export { CopyIconButton } from "./components/CopyIconButton";
|
export { CopyIconButton } from "./components/CopyIconButton";
|
||||||
export { CountBadge } from "./components/CountBadge";
|
export { CountBadge } from "./components/CountBadge";
|
||||||
export type { DetailsBannerProps } from "./components/DetailsBanner";
|
export type { DetailsBannerProps } from "./components/DetailsBanner";
|
||||||
export { DetailsBanner } from "./components/DetailsBanner";
|
export { DetailsBanner } from "./components/DetailsBanner";
|
||||||
|
export type { DialogFooterAction, DialogProps } from "./components/Dialog";
|
||||||
|
export { Dialog, DialogFooter } from "./components/Dialog";
|
||||||
export { DropMarker } from "./components/DropMarker";
|
export { DropMarker } from "./components/DropMarker";
|
||||||
export type {
|
export type {
|
||||||
ContextMenuProps,
|
ContextMenuProps,
|
||||||
@@ -40,6 +44,8 @@ export { KeyValueRow, KeyValueRows } from "./components/KeyValueRow";
|
|||||||
export { Label } from "./components/Label";
|
export { Label } from "./components/Label";
|
||||||
export { LoadingIcon } from "./components/LoadingIcon";
|
export { LoadingIcon } from "./components/LoadingIcon";
|
||||||
export { Overlay } from "./components/Overlay";
|
export { Overlay } from "./components/Overlay";
|
||||||
|
export type { PlainInputProps } from "./components/PlainInput";
|
||||||
|
export { PlainInput } from "./components/PlainInput";
|
||||||
export { Portal } from "./components/Portal";
|
export { Portal } from "./components/Portal";
|
||||||
export type { SeparatorAction } from "./components/Separator";
|
export type { SeparatorAction } from "./components/Separator";
|
||||||
export { Separator } from "./components/Separator";
|
export { Separator } from "./components/Separator";
|
||||||
@@ -68,6 +74,9 @@ export type {
|
|||||||
TabsStorage,
|
TabsStorage,
|
||||||
} from "./components/tabs/Tabs";
|
} from "./components/tabs/Tabs";
|
||||||
export { TabContent, Tabs } from "./components/tabs/Tabs";
|
export { TabContent, Tabs } from "./components/tabs/Tabs";
|
||||||
|
export type { ToastProps } from "./components/Toast";
|
||||||
|
export { Toast } from "./components/Toast";
|
||||||
|
export { Toasts } from "./components/Toasts";
|
||||||
export { HttpMethodTag, HttpMethodTagRaw } from "./components/tags/HttpMethodTag";
|
export { HttpMethodTag, HttpMethodTagRaw } from "./components/tags/HttpMethodTag";
|
||||||
export { formatMillis, HttpResponseDurationTag } from "./components/tags/HttpResponseDurationTag";
|
export { formatMillis, HttpResponseDurationTag } from "./components/tags/HttpResponseDurationTag";
|
||||||
export { HttpStatusTag, HttpStatusTagRaw } from "./components/tags/HttpStatusTag";
|
export { HttpStatusTag, HttpStatusTagRaw } from "./components/tags/HttpStatusTag";
|
||||||
@@ -104,6 +113,7 @@ export type {
|
|||||||
} from "./hooks/useHotKey";
|
} from "./hooks/useHotKey";
|
||||||
export { formatHotkey, HotkeyProvider, useFormattedHotkey, useHotKey } from "./hooks/useHotKey";
|
export { formatHotkey, HotkeyProvider, useFormattedHotkey, useHotKey } from "./hooks/useHotKey";
|
||||||
export { useIsFullscreen } from "./hooks/useIsFullscreen";
|
export { useIsFullscreen } from "./hooks/useIsFullscreen";
|
||||||
|
export { useRandomKey } from "./hooks/useRandomKey";
|
||||||
export { usePortal } from "./hooks/usePortal";
|
export { usePortal } from "./hooks/usePortal";
|
||||||
export { useStateWithDeps } from "./hooks/useStateWithDeps";
|
export { useStateWithDeps } from "./hooks/useStateWithDeps";
|
||||||
export type { SetStoredValue, StoredValue, UseStoredValue } from "./hooks/useStoredValue";
|
export type { SetStoredValue, StoredValue, UseStoredValue } from "./hooks/useStoredValue";
|
||||||
@@ -118,3 +128,5 @@ export { computeSideForDragMove } from "./lib/dnd";
|
|||||||
export { docFingerprint } from "./lib/docFingerprint";
|
export { docFingerprint } from "./lib/docFingerprint";
|
||||||
export { getNodeText } from "./lib/getNodeText";
|
export { getNodeText } from "./lib/getNodeText";
|
||||||
export { minPromiseMillis } from "./lib/minPromiseMillis";
|
export { minPromiseMillis } from "./lib/minPromiseMillis";
|
||||||
|
export type { ShowToastArgs, ToastInstance } from "./lib/toast";
|
||||||
|
export { hideToast, hideToastById, showToast, toastsAtom } from "./lib/toast";
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { unnamedRequestName } from "./resolvedModelName";
|
||||||
|
|
||||||
|
describe("unnamedRequestName", () => {
|
||||||
|
test("is the URL without its protocol, as Yaak shows an unnamed request", () => {
|
||||||
|
const url = "http://ocsp2.apple.com/ocsp04-wwdrg312/ME4wTKADAgEAMEUwQzBBMAkGBSsOAwIaBQAEFA";
|
||||||
|
expect(unnamedRequestName({ model: "http_request", url })).toBe(
|
||||||
|
"ocsp2.apple.com/ocsp04-wwdrg312/ME4wTKADAgEAMEUwQzBBMAkGBSsOAwIaBQAEFA",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("names the kind of request when there is no URL", () => {
|
||||||
|
expect(unnamedRequestName({ model: "http_request", url: "" })).toBe("HTTP Request");
|
||||||
|
expect(unnamedRequestName({ model: "http_request", url: " ", bodyType: "graphql" })).toBe(
|
||||||
|
"GraphQL Request",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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