diff --git a/Cargo.lock b/Cargo.lock index 09736a5ce..577ac7215 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11375,6 +11375,8 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-opener", "tauri-plugin-os", "yaak-mac-window", "yaak-proxy-lib", @@ -11765,6 +11767,10 @@ dependencies = [ "include_dir", "libc", "log 0.4.34", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "plist", "r2d2", "r2d2_sqlite", "reqwest", diff --git a/apps/yaak-client/components/ErrorBoundary.tsx b/apps/yaak-client/components/ErrorBoundary.tsx index cba573a8f..dfc4f85d3 100644 --- a/apps/yaak-client/components/ErrorBoundary.tsx +++ b/apps/yaak-client/components/ErrorBoundary.tsx @@ -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: () => }); } diff --git a/apps/yaak-client/components/Toasts.tsx b/apps/yaak-client/components/Toasts.tsx index 046f1919a..95e2f4253 100644 --- a/apps/yaak-client/components/Toasts.tsx +++ b/apps/yaak-client/components/Toasts.tsx @@ -1,43 +1,6 @@ -import { useAtomValue } from "jotai"; -import { AnimatePresence } from "motion/react"; -import type { ReactNode } from "react"; -import { hideToast, toastsAtom } from "../lib/toast"; -import { Toast, type ToastProps } from "./core/Toast"; -import { ErrorBoundary } from "./ErrorBoundary"; -import { Portal } from "@yaakapp-internal/ui"; +import { Toasts as BaseToasts } from "@yaakapp-internal/ui"; +import { showErrorDialog } from "./ErrorBoundary"; -export type ToastInstance = { - id: string; - uniqueKey: string; - message: ReactNode; - timeout: 3000 | 5000 | 8000 | (number & {}) | null; - onClose?: ToastProps["onClose"]; -} & Omit; +export type { ToastInstance } from "@yaakapp-internal/ui"; -export const Toasts = () => { - const toasts = useAtomValue(toastsAtom); - return ( - -
- - {toasts.map((toast: ToastInstance) => { - const { message, uniqueKey, ...props } = toast; - return ( - - hideToast(toast)} - > - {message} - - - ); - })} - -
-
- ); -}; +export const Toasts = () => ; diff --git a/apps/yaak-client/components/core/Checkbox.tsx b/apps/yaak-client/components/core/Checkbox.tsx index 670efe94f..787549415 100644 --- a/apps/yaak-client/components/core/Checkbox.tsx +++ b/apps/yaak-client/components/core/Checkbox.tsx @@ -1,72 +1,2 @@ -import { HStack, Icon, IconTooltip } from "@yaakapp-internal/ui"; -import classNames from "classnames"; -import type { ReactNode } from "react"; - -export interface CheckboxProps { - checked: boolean | "indeterminate"; - title: ReactNode; - onChange: (checked: boolean) => void; - className?: string; - disabled?: boolean; - inputWrapperClassName?: string; - hideLabel?: boolean; - fullWidth?: boolean; - help?: ReactNode; - size?: "sm" | "md"; -} - -export function Checkbox({ - checked, - onChange, - className, - inputWrapperClassName, - disabled, - title, - hideLabel, - fullWidth, - help, - size = "sm", -}: CheckboxProps) { - return ( - -
- { - onChange(checked === "indeterminate" ? true : !checked); - }} - /> -
- -
-
- {!hideLabel && ( -
- {title} -
- )} - {help && } -
- ); -} +export type { CheckboxProps } from "@yaakapp-internal/ui"; +export { Checkbox } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/core/Dialog.tsx b/apps/yaak-client/components/core/Dialog.tsx index 00f546921..00076ec06 100644 --- a/apps/yaak-client/components/core/Dialog.tsx +++ b/apps/yaak-client/components/core/Dialog.tsx @@ -1,200 +1,2 @@ -import type { DialogSize } from "@yaakapp-internal/plugins"; -import { Heading, HStack, Overlay } from "@yaakapp-internal/ui"; -import classNames from "classnames"; -import * as m from "motion/react-m"; -import type { ReactNode } from "react"; -import { createContext, useContext, useMemo, useState } from "react"; -import { createPortal } from "react-dom"; -import { Button, type ButtonProps } from "./Button"; -import { IconButton } from "./IconButton"; - -export interface DialogProps { - children: ReactNode; - open: boolean; - onClose?: () => void; - /** Block dismissal from the backdrop, Escape key, and built-in close button. */ - disableClose?: boolean; - title?: ReactNode; - description?: ReactNode; - className?: string; - size?: DialogSize; - /** Hide the built-in close button without changing backdrop or Escape behavior. */ - hideX?: boolean; - noPadding?: boolean; - noScroll?: boolean; - vAlign?: "top" | "center"; -} - -export function Dialog({ - children, - className, - size = "full", - open, - onClose, - disableClose, - title, - description, - hideX, - noPadding, - noScroll, - vAlign = "center", -}: DialogProps) { - const titleId = useMemo(() => Math.random().toString(36).slice(2), []); - const [footerEl, setFooterEl] = useState(null); - const descriptionId = useMemo( - () => (description ? Math.random().toString(36).slice(2) : undefined), - [description], - ); - - return ( - -
{ - // NOTE: We handle Escape on the element itself so that it doesn't close multiple - // dialogs and can be intercepted by children if needed. - if (e.key === "Escape") { - if (!disableClose) onClose?.(); - e.stopPropagation(); - e.preventDefault(); - } - }} - > - - {title ? ( - - {title} - - ) : ( - - )} - - {description ? ( -
- {description} -
- ) : ( - - )} - -
- {children} -
- -
- - {/*Put close at the end so that it's the last thing to be tabbed to*/} - {!disableClose && !hideX && ( -
- -
- )} - -
- - ); -} - -const DialogFooterSlot = createContext(null); - -export interface DialogFooterAction { - label: string; - onClick?: () => void; - /** Submit the form with this id, which can be anywhere in the dialog body */ - form?: string; - color?: ButtonProps["color"]; - variant?: ButtonProps["variant"]; - disabled?: boolean; - isLoading?: boolean; - autoFocus?: boolean; - leftSlot?: ReactNode; - rightSlot?: ReactNode; -} - -/** - * The action row at the bottom of a dialog. Render it anywhere in the dialog's content and it is - * placed below the scrolling body, outside its padding. `inline` drops the divider for small - * dialogs whose body never scrolls. - */ -export function DialogFooter({ - actions, - leftSlot, - inline, -}: { - actions: DialogFooterAction[]; - leftSlot?: ReactNode; - inline?: boolean; -}) { - const slot = useContext(DialogFooterSlot); - const footer = ( -
- {leftSlot != null &&
{leftSlot}
} - - {actions.map((action, i) => ( - - ))} - -
- ); - return slot == null ? footer : createPortal(footer, slot); -} +export type { DialogFooterAction, DialogProps } from "@yaakapp-internal/ui"; +export { Dialog, DialogFooter } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/core/PlainInput.tsx b/apps/yaak-client/components/core/PlainInput.tsx index 473ab5663..17e865755 100644 --- a/apps/yaak-client/components/core/PlainInput.tsx +++ b/apps/yaak-client/components/core/PlainInput.tsx @@ -1,242 +1,2 @@ -import { HStack, useStateWithDeps, Label } from "@yaakapp-internal/ui"; -import classNames from "classnames"; -import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react"; -import { - forwardRef, - useCallback, - useImperativeHandle, - useLayoutEffect, - useRef, - useState, -} from "react"; -import { useRandomKey } from "../../hooks/useRandomKey"; -import { IconButton } from "./IconButton"; -import type { InputProps } from "./Input"; -import { generateId } from "@yaakapp-internal/lib/generateId"; - -export type PlainInputProps = Omit< - InputProps, - | "wrapLines" - | "onKeyDown" - | "type" - | "stateKey" - | "autocompleteVariables" - | "autocompleteFunctions" - | "autocomplete" - | "extraExtensions" - | "forcedEnvironmentId" -> & - Pick, "inputMode" | "onKeyDownCapture" | "step"> & { - onFocusRaw?: InputHTMLAttributes["onFocus"]; - type?: "text" | "password" | "number"; - hideObscureToggle?: boolean; - labelRightSlot?: ReactNode; - }; - -export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(function PlainInput( - { - autoFocus, - autoSelect, - className, - containerClassName, - defaultValue, - disabled, - forceUpdateKey: forceUpdateKeyFromAbove, - help, - hideLabel, - hideObscureToggle, - label, - labelClassName, - labelPosition = "top", - labelRightSlot, - inputMode, - leftSlot, - name, - onBlur, - onChange, - onFocus, - onFocusRaw, - onKeyDownCapture, - onPaste, - placeholder, - required, - rightSlot, - size = "md", - step, - tint, - type = "text", - validate, - }, - ref, -) { - // Track a local key for updates. If the default value is changed when the input is not in focus, - // regenerate this to force the field to update. - const [focusedUpdateKey, regenerateFocusedUpdateKey] = useRandomKey(); - const forceUpdateKey = `${forceUpdateKeyFromAbove}::${focusedUpdateKey}`; - - const [obscured, setObscured] = useStateWithDeps(type === "password", [type]); - const [focused, setFocused] = useState(false); - const [hasChanged, setHasChanged] = useStateWithDeps(false, [forceUpdateKey]); - const textareaRef = useRef(null); - const inputRef = useRef(null); - - useImperativeHandle<{ focus: () => void } | null, { focus: () => void } | null>( - ref, - () => inputRef.current, - ); - - const handleFocus = useCallback( - (e: FocusEvent) => { - onFocusRaw?.(e); - setFocused(true); - if (autoSelect) { - inputRef.current?.select(); - textareaRef.current?.select(); - } - onFocus?.(); - }, - [autoSelect, onFocus, onFocusRaw], - ); - - const handleBlur = useCallback(() => { - setFocused(false); - onBlur?.(); - }, [onBlur]); - - // Force input to update when receiving change and not in focus - useLayoutEffect(() => { - const isFocused = document.activeElement === inputRef.current; - if (defaultValue != null && !isFocused) { - regenerateFocusedUpdateKey(); - } - }, [regenerateFocusedUpdateKey, defaultValue]); - - const id = useRef(`input-${generateId()}`); - const commonClassName = classNames( - className, - "bg-transparent! min-w-0 w-full focus:outline-hidden placeholder:text-placeholder", - "px-2 text-xs font-mono cursor-text", - ); - - const handleChange = useCallback( - (value: string) => { - onChange?.(value); - setHasChanged(true); - const isValid = (value: string) => { - if (required && !validateRequire(value)) return false; - if (typeof validate === "boolean") return validate; - if (typeof validate === "function" && !validate(value)) return false; - return true; - }; - inputRef.current?.setCustomValidity(isValid(value) ? "" : "Invalid value"); - }, - [onChange, required, setHasChanged, validate], - ); - - const wrapperRef = useRef(null); - - return ( -
- - - {tint != null && ( -
- )} - {leftSlot} - - 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} - /> - - {type === "password" && !hideObscureToggle && ( - setObscured((o) => !o)} - /> - )} - {rightSlot} - -
- ); -}); - -function validateRequire(v: string) { - return v.length > 0; -} +export type { PlainInputProps } from "@yaakapp-internal/ui"; +export { PlainInput } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/core/Toast.tsx b/apps/yaak-client/components/core/Toast.tsx index b851a993f..c8c06785c 100644 --- a/apps/yaak-client/components/core/Toast.tsx +++ b/apps/yaak-client/components/core/Toast.tsx @@ -1,148 +1,2 @@ -import type { ShowToastRequest } from "@yaakapp-internal/plugins"; -import { Icon, type IconProps, VStack } from "@yaakapp-internal/ui"; -import classNames from "classnames"; -import * as m from "motion/react-m"; -import type { ReactNode } from "react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { useKey } from "react-use"; -import { IconButton } from "./IconButton"; - -export interface ToastProps { - children: ReactNode; - open: boolean; - onClose: () => void; - className?: string; - timeout: number | null; - action?: (args: { hide: () => void }) => ReactNode; - icon?: ShowToastRequest["icon"] | null; - color?: ShowToastRequest["color"]; - // Grow with the content (up to the viewport) instead of scrolling internally - // past the default max height - dynamicHeight?: boolean; - // Hide the close button, for toasts that render their own dismiss action. - // Escape still closes the toast - hideDismiss?: boolean; -} - -const ICONS: Record, 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 | 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 ( - -
-
- {toastIcon && } - -
{children}
- {action?.({ hide: onClose })} -
-
- - {!hideDismiss && ( - - )} - - {timeout != null && !autoHideCanceled && ( -
- -
- )} -
-
- ); -} +export type { ToastProps } from "@yaakapp-internal/ui"; +export { Toast } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/hooks/useRandomKey.ts b/apps/yaak-client/hooks/useRandomKey.ts index 9a73cf832..c3f42f26e 100644 --- a/apps/yaak-client/hooks/useRandomKey.ts +++ b/apps/yaak-client/hooks/useRandomKey.ts @@ -1,8 +1 @@ -import { useCallback, useState } from "react"; -import { generateId } from "@yaakapp-internal/lib/generateId"; - -export function useRandomKey(initialValue?: string) { - const [value, setValue] = useState(initialValue ?? generateId()); - const regenerate = useCallback(() => setValue(generateId()), []); - return [value, regenerate] as const; -} +export { useRandomKey } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/lib/resolvedModelName.ts b/apps/yaak-client/lib/resolvedModelName.ts index 1b7dcfcfd..166c0145a 100644 --- a/apps/yaak-client/lib/resolvedModelName.ts +++ b/apps/yaak-client/lib/resolvedModelName.ts @@ -1,43 +1,9 @@ import type { AnyModel } from "@yaakapp-internal/models"; import { foldersAtom } from "@yaakapp-internal/models"; +import { resolvedModelName } from "@yaakapp-internal/ui/resolvedModelName"; import { jotaiStore } from "./jotai"; -import { replaceTemplateTags } from "./templateTags"; -export function resolvedModelName(r: AnyModel | null): string { - if (r == null) return ""; - - if (!("url" in r) || r.model === "plugin") { - return "name" in r ? r.name : ""; - } - - // Return name if it has one - if ("name" in r && r.name) { - return r.name; - } - - // Replace variable syntax with variable name - const withoutVariables = replaceTemplateTags(r.url, (m) => m.inner.trim()); - if (withoutVariables.trim() === "") { - return r.model === "http_request" - ? r.bodyType && r.bodyType === "graphql" - ? "GraphQL Request" - : "HTTP Request" - : r.model === "websocket_request" - ? "WebSocket Request" - : "gRPC Request"; - } - - // GRPC gets nice short names - if (r.model === "grpc_request" && r.service != null && r.method != null) { - const shortService = r.service.split(".").pop(); - return `${shortService}/${r.method}`; - } - - // Strip unnecessary protocol - const withoutProto = withoutVariables.replace(/^(http|https|ws|wss):\/\//, ""); - - return withoutProto; -} +export { resolvedModelName }; export function resolvedModelNameWithFolders(model: AnyModel | null): string { return resolvedModelNameWithFoldersArray(model).join(" / "); diff --git a/apps/yaak-client/lib/templateTags.ts b/apps/yaak-client/lib/templateTags.ts index b832321fc..b105ce627 100644 --- a/apps/yaak-client/lib/templateTags.ts +++ b/apps/yaak-client/lib/templateTags.ts @@ -1,137 +1,2 @@ -/* - * Finding template tags in text. - * - * A tag closes at `]}`, but a quoted argument is allowed to contain `]}` of its own — the - * printer in `crates/yaak-templates` emits `${[ fn(a='x]}y') ]}` verbatim — so the scan has to - * step over quoted strings. It mirrors the Lezer grammar in - * `components/core/Editor/twig/twig.grammar`: a terminated `'…'` (where `\` escapes whatever - * follows it) is consumed whole, and an unterminated quote is just another character, so a - * half-typed tag still closes at the next `]}` instead of swallowing the rest of the document. - * - * This was a regex until CodeQL flagged it (js/polynomial-redos): matching an unterminated - * quote needed a negative lookahead that rescanned to the end of the input at every unpaired - * quote, which is quadratic on text an attacker picks. The scan below is linear instead — see - * the notes on `closingQuoteTable` and on the dead-end set in `findTemplateTags` for why. - */ - -const QUOTE = 0x27; // ' -const BACKSLASH = 0x5c; // \ -const CLOSE_BRACKET = 0x5d; // ] -const CLOSE_BRACE = 0x7d; // } - -/** The `${[`…`]}` bounds of one tag, and what sits between them. */ -export interface TemplateTagMatch { - /** Index of the `$` that opens the tag. */ - start: number; - /** Index just past the closing `]}`. */ - end: number; - /** Everything between `${[` and `]}`, not trimmed. */ - inner: string; -} - -/** - * For every index `i`, the index of the `'` that closes a string whose body starts at `i`, or - * `-1` when nothing does. - * - * Built right to left in a single pass, which is the trick that replaces the regex lookahead: - * whether a quote opens a terminated string depends only on the text after it, so the answer - * can be computed once for every position instead of re-derived at each quote. Skipping a - * string of any length then costs one lookup. - */ -function closingQuoteTable(text: string): Int32Array { - // Two extra slots so a `\` in the last position can read past the end and find `-1` - const table = new Int32Array(text.length + 2).fill(-1); - for (let i = text.length - 1; i >= 0; i--) { - const c = text.charCodeAt(i); - // A backslash escapes whatever follows it, including a quote - const next = c === BACKSLASH ? table[i + 2] : table[i + 1]; - table[i] = c === QUOTE ? i : (next ?? -1); - } - return table; -} - -/** - * Finds every template tag in `text`, in order and without overlaps. - * - * A `${[` with no `]}` after it isn't a tag, and neither is a `${[` nested inside a tag body — - * both are ordinary content, same as the regex this replaced treated them. - */ -export function findTemplateTags(text: string): TemplateTagMatch[] { - const matches: TemplateTagMatch[] = []; - - // Both built only once a scan actually needs them, so short values allocate nothing - let closingQuotes: Int32Array | null = null; - - /* - * Body positions a previous scan already ran off the end of the text from. Where a scan ends - * up depends only on where it starts, so a scan that walks into one of these would repeat - * that same walk and fail the same way, and can stop right there. - * - * That bound is what keeps the whole pass linear: every position enters this set at most - * once, and every step of every scan either visits a position for the first time or ends - * that scan. - */ - let deadEnds: Set | null = null; - - let searchFrom = 0; - while (searchFrom < text.length) { - const start = text.indexOf("${[", searchFrom); - if (start < 0) break; - - const visited: number[] = []; - let end = -1; - let i = start + 3; - while (i < text.length) { - if (deadEnds?.has(i)) break; - visited.push(i); - - const c = text.charCodeAt(i); - if (c === QUOTE) { - closingQuotes ??= closingQuoteTable(text); - const close = closingQuotes[i + 1] ?? -1; - // An unterminated quote is an ordinary character, so only step over a closed string - i = close < 0 ? i + 1 : close + 1; - continue; - } - - if (c === CLOSE_BRACKET && text.charCodeAt(i + 1) === CLOSE_BRACE) { - end = i + 2; - break; - } - - i += 1; - } - - if (end < 0) { - // Not a tag. Resume past the opener: a real tag may start inside a string this scan - // stepped over. - deadEnds ??= new Set(); - for (const position of visited) deadEnds.add(position); - searchFrom = start + 3; - continue; - } - - matches.push({ start, end, inner: text.slice(start + 3, end - 2) }); - searchFrom = end; - } - - return matches; -} - -/** Rewrites every template tag in `text` with whatever `replace` returns for it. */ -export function replaceTemplateTags( - text: string, - replace: (match: TemplateTagMatch) => string, -): string { - const matches = findTemplateTags(text); - if (matches.length === 0) return text; - - let result = ""; - let copiedTo = 0; - for (const match of matches) { - result += text.slice(copiedTo, match.start) + replace(match); - copiedTo = match.end; - } - - return result + text.slice(copiedTo); -} +export type { TemplateTagMatch } from "@yaakapp-internal/ui/templateTags"; +export { findTemplateTags, replaceTemplateTags } from "@yaakapp-internal/ui/templateTags"; diff --git a/apps/yaak-client/lib/toast.tsx b/apps/yaak-client/lib/toast.tsx index 26678d544..737fdec04 100644 --- a/apps/yaak-client/lib/toast.tsx +++ b/apps/yaak-client/lib/toast.tsx @@ -1,50 +1,25 @@ -import { atom } from "jotai"; -import type { ToastInstance } from "../components/Toasts"; -import { generateId } from "@yaakapp-internal/lib/generateId"; +// The toast store alone, without the components, which tests in a plain runtime can't load +import { + hideToast as hideToastIn, + hideToastById as hideToastByIdIn, + type ShowToastArgs, + showToast as showToastIn, + type ToastInstance, +} from "@yaakapp-internal/ui/toast"; import { jotaiStore } from "./jotai"; -export const toastsAtom = atom([]); +export { toastsAtom } from "@yaakapp-internal/ui/toast"; -export function showToast({ - id, - timeout = 5000, - ...props -}: Omit & { - id?: ToastInstance["id"]; - timeout?: ToastInstance["timeout"]; -}) { - id = id ?? generateId(); - const uniqueKey = generateId(); - - const toasts = jotaiStore.get(toastsAtom); - const toastWithSameId = toasts.find((t) => t.id === id); - - let delay = 0; - if (toastWithSameId) { - hideToast(toastWithSameId); - // Allow enough time for old toast to animate out - delay = 200; - } - - setTimeout(() => { - const newToast: ToastInstance = { id, uniqueKey, timeout, ...props }; - jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]); - }, delay); - - return id; +export function showToast(args: ShowToastArgs) { + return showToastIn(jotaiStore, args); } export function hideToastById(id: string) { - const toast = jotaiStore.get(toastsAtom).find((t) => t.id === id); - if (toast) hideToast(toast); + hideToastByIdIn(jotaiStore, id); } export function hideToast(toHide: ToastInstance) { - jotaiStore.set(toastsAtom, (all) => { - const t = all.find((t) => t.uniqueKey === toHide.uniqueKey); - t?.onClose?.(); - return all.filter((t) => t.uniqueKey !== toHide.uniqueKey); - }); + hideToastIn(jotaiStore, toHide); } export function showErrorToast({ diff --git a/apps/yaak-proxy/components/AppIcon.tsx b/apps/yaak-proxy/components/AppIcon.tsx new file mode 100644 index 000000000..e132ee021 --- /dev/null +++ b/apps/yaak-proxy/components/AppIcon.tsx @@ -0,0 +1,49 @@ +import classNames from "classnames"; +import { useEffect, useState } from "react"; +import { rpc } from "../lib/rpc"; + +/** + * Icons by app path, as data URLs once loaded, for the whole session. Plain rather than a query + * per icon, since every new row in the list shows one. + */ +const icons = new Map(); +const loading = new Map>(); + +function loadIcon(path: string): Promise { + let promise = loading.get(path); + if (promise == null) { + promise = rpc("app_icon", { path }) + .then((png) => (png == null ? null : `data:image/png;base64,${png}`)) + .catch(() => null) + .then((url) => { + icons.set(path, url); + return url; + }); + loading.set(path, promise); + } + return promise; +} + +/** An app's icon, read once per path for the whole session */ +export function AppIcon({ path, className }: { path: string; className?: string }) { + const [url, setUrl] = useState(() => icons.get(path)); + useEffect(() => { + if (icons.has(path)) { + setUrl(icons.get(path)); + return; + } + let current = true; + void loadIcon(path).then((loaded) => { + if (current) setUrl(loaded); + }); + return () => { + current = false; + }; + }, [path]); + + return url == null ? ( + + ) : ( + + ); +} diff --git a/apps/yaak-proxy/components/FirstRun.tsx b/apps/yaak-proxy/components/FirstRun.tsx new file mode 100644 index 000000000..987bb07b4 --- /dev/null +++ b/apps/yaak-proxy/components/FirstRun.tsx @@ -0,0 +1,225 @@ +import { Button, Icon, LoadingIcon } from "@yaakapp-internal/ui"; +import classNames from "classnames"; +import { useAtomValue } from "jotai"; +import type { ReactNode } from "react"; +import { useRpcMutation } from "../hooks/useRpcMutation"; +import { proxyStateAtom } from "../lib/store"; +import { useCopyEnvironment, useProxySetup, useSystemProxySwitch } from "./ProxyStatus"; + +type StepState = "todo" | "next" | "wait" | "done" | "fail"; + +/** + * The list's empty state until traffic arrives: trust the certificate, turn on the system proxy, + * then see traffic. Not dismissable and nothing remembered; the first flow replaces it. Copy + * environment beside it serves someone who wants one terminal proxied and nothing system-wide. + */ +export function FirstRun() { + const proxyState = useAtomValue(proxyStateAtom); + const setup = useProxySetup(); + const { caStatus, trust, systemProxy, setSystemProxy } = setup; + const systemSwitch = useSystemProxySwitch(setup); + const execute = useRpcMutation("execute_action"); + const retry = useRpcMutation("take_over"); + const copyEnvironment = useCopyEnvironment(); + + const running = proxyState?.state === "running"; + // Changed only by the process that owns the engine, so not from here while the CLI does. + const otherOwner = proxyState?.otherOwner != null; + const recording = proxyState?.recording !== false; + const trusted = caStatus.data?.trusted === true; + const systemSupported = systemProxy.data?.supported !== false; + const systemOn = systemProxy.data?.enabled === true; + + const trustState: StepState = trusted + ? "done" + : trust.isPending + ? "wait" + : trust.error != null + ? "fail" + : "next"; + const systemState: StepState = setSystemProxy.isPending + ? "wait" + : systemOn + ? "done" + : systemSwitch.failed + ? "fail" + : trusted + ? "next" + : "todo"; + const ready = trusted && (systemOn || !systemSupported); + const trafficState: StepState = !running && !otherOwner ? "fail" : ready ? "wait" : "todo"; + + return ( +
+
+
Set up Yaak Proxy
+
+ Three steps, once. Afterwards every app on this Mac sends its traffic here, and HTTPS + reads as plain text. +
+
+ trust.mutate({})} + > + {trustState === "fail" ? "Try again" : "Trust certificate"} + + ) : null + } + /> + {systemSupported && ( + setSystemProxy.mutate({ enabled: true })} + > + {systemState === "fail" ? "Try again" : "Turn on"} + + ) : null + } + /> + )} + retry.mutate({})} + > + Retry + + ) : !recording && running ? ( + + ) : null + } + /> +
+
+ Only need one terminal or one app? Copy the environment instead. + +
+
+
+ ); +} + +function Step({ + n, + state, + title, + description, + action, +}: { + n: number; + state: StepState; + title: string; + description: ReactNode; + action: ReactNode; +}) { + return ( +
+ +
+
+ {title} +
+
+ {description} +
+
+
{action}
+
+ ); +} + +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 ( + + + + ); + case "fail": + return ( + + + + ); + case "wait": + return ( + + + + ); + case "next": + return {n}; + case "todo": + return {n}; + } +} diff --git a/apps/yaak-proxy/components/FlowDetail.tsx b/apps/yaak-proxy/components/FlowDetail.tsx index bdddc84b8..5064045a6 100644 --- a/apps/yaak-proxy/components/FlowDetail.tsx +++ b/apps/yaak-proxy/components/FlowDetail.tsx @@ -10,6 +10,7 @@ import { HStack, HttpStatusTagRaw, Icon, + IconButton, SizeTag, type TabButtonRenderProps, type TabItem, @@ -17,11 +18,12 @@ import { Tabs, } from "@yaakapp-internal/ui"; import classNames from "classnames"; -import { useAtomValue } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import type { CSSProperties } from "react"; import { useMemo, useState } from "react"; import { selectedFlowAtom } from "../lib/atoms"; import { findHeader, mimeEssence } from "../lib/body"; +import { saveDialogFlowAtom, useFlowActions } from "../lib/flowActions"; import { statusReason } from "../lib/statusReason"; import { EmptyState, FlowBodyViewer, useFlowBody } from "./FlowBodyViewer"; import { FlowHeaders, FlowQuery, queryParams } from "./FlowHeaders"; @@ -50,10 +52,8 @@ export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideB
Select a flow - Start the proxy - - Stop the proxy - + Pause or resume recording + Clear flows
@@ -73,6 +73,8 @@ function FlowPane({ flow }: { flow: Flow }) { const noResponse = flow.error != null && flow.status == null; const mime = mimeEssence(findHeader(flow.responseHeaders, "content-type")); const queryCount = useMemo(() => queryParams(flow.url).length, [flow.url]); + const openSave = useSetAtom(saveDialogFlowAtom); + const flowActions = useFlowActions(); const tabs = useMemo( () => [ @@ -125,7 +127,7 @@ function FlowPane({ flow }: { flow: Flow }) {
)} + + + + + +
diff --git a/apps/yaak-proxy/components/FlowList.tsx b/apps/yaak-proxy/components/FlowList.tsx index f73f3d074..2d5b03bb4 100644 --- a/apps/yaak-proxy/components/FlowList.tsx +++ b/apps/yaak-proxy/components/FlowList.tsx @@ -1,17 +1,34 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { formatSize } from "@yaakapp-internal/lib/formatSize"; import type { Flow } from "@yaakapp-internal/proxy-lib"; -import { formatMillis, HttpMethodTagRaw, HttpStatusTagRaw, Icon } from "@yaakapp-internal/ui"; +import { + ContextMenu, + formatMillis, + HttpMethodTagRaw, + HttpStatusTagRaw, + Icon, +} from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import type { CSSProperties, KeyboardEvent, WheelEvent } from "react"; +import type { CSSProperties, KeyboardEvent, MouseEvent, WheelEvent } from "react"; import { memo, useCallback, useLayoutEffect, useRef, useState } from "react"; import { selectedFlowAtom, selectedFlowIdAtom } from "../lib/atoms"; import { shortContentType } from "../lib/body"; +import { useFlowActions } from "../lib/flowActions"; import { compareFlows } from "../lib/flowWindow"; import { countNewFlows } from "../lib/follow"; -import { followAtom, loadOlderFlowsAtom, newestStartedAtAtom, oldestFlowAtom } from "../lib/store"; -import { filteredExchangesAtom } from "./Sidebar"; +import { + activeQueryAtom, + followAtom, + hasAppsAtom, + loadOlderFlowsAtom, + newestStartedAtAtom, + oldestFlowAtom, + searchWindowAtom, + shownFlowsAtom, +} from "../lib/store"; +import { AppIcon } from "./AppIcon"; +import { SearchBar } from "./SearchBar"; const ROW_HEIGHT = 24; const BOTTOM_PADDING = 6; @@ -29,7 +46,10 @@ interface Props { * the top loads older flows from the database. */ export function FlowList({ sideBySide, style, className }: Props) { - const flows = useAtomValue(filteredExchangesAtom); + const flows = useAtomValue(shownFlowsAtom); + const hasApps = useAtomValue(hasAppsAtom); + const [menu, setMenu] = useState<{ flow: Flow; x: number; y: number } | null>(null); + const flowActions = useFlowActions(); const selectedId = useAtomValue(selectedFlowIdAtom); const setSelectedFlow = useSetAtom(selectedFlowAtom); const loadOlder = useSetAtom(loadOlderFlowsAtom); @@ -153,17 +173,33 @@ export function FlowList({ sideBySide, style, className }: Props) { [flows, loadOlderHeld, select, selectedId], ); + const openMenu = useCallback( + (index: number, e: MouseEvent) => { + const flow = latest.current.flows[index]; + if (flow == null) return; + e.preventDefault(); + select(index); + setMenu({ flow, x: e.clientX, y: e.clientY }); + }, + [select], + ); + const newCount = following ? 0 : countNewFlows(flows, heldAt); const columns = sideBySide - ? "grid-cols-[0.9rem_2.6rem_minmax(0,2fr)_minmax(0,3fr)_2.4rem_3.6rem]" - : "grid-cols-[0.9rem_2.6rem_13rem_minmax(0,1fr)_2.4rem_5rem_4.5rem_3.8rem]"; + ? hasApps + ? "grid-cols-[0.9rem_1rem_2.6rem_minmax(0,2fr)_minmax(0,3fr)_2.4rem_3.6rem]" + : "grid-cols-[0.9rem_2.6rem_minmax(0,2fr)_minmax(0,3fr)_2.4rem_3.6rem]" + : hasApps + ? "grid-cols-[0.9rem_8.5rem_2.6rem_13rem_minmax(0,1fr)_2.4rem_5rem_4.5rem_3.8rem]" + : "grid-cols-[0.9rem_2.6rem_13rem_minmax(0,1fr)_2.4rem_5rem_4.5rem_3.8rem]"; return (
+
+ {hasApps && {sideBySide ? "" : "App"}} Method Host Path @@ -192,7 +229,7 @@ export function FlowList({ sideBySide, style, className }: Props) { className="group/flows h-full overflow-y-auto px-1.5 outline-hidden" > {flows.length === 0 ? ( -

No traffic yet

+ ) : (
{virtualizer.getVirtualItems().map((item) => { @@ -206,8 +243,10 @@ export function FlowList({ sideBySide, style, className }: Props) { start={item.start} columns={columns} sideBySide={sideBySide} + showApp={hasApps} selected={flow.id === selectedId} onSelect={select} + onContextMenu={openMenu} /> ); })} @@ -229,19 +268,38 @@ export function FlowList({ sideBySide, style, className }: Props) { {newCount === 1 ? "1 new flow" : `${newCount} new flows`} )} + setMenu(null)} + />
); } +/** Nothing to list, or nothing a search matches */ +function EmptyList() { + const query = useAtomValue(activeQueryAtom); + const search = useAtomValue(searchWindowAtom); + if (search?.loading) return null; + return ( +

+ {query == null ? "No traffic yet" : "No flows match"} +

+ ); +} + interface RowProps { flow: Flow; index: number; start: number; columns: string; sideBySide: boolean; + showApp: boolean; selected: boolean; onSelect: (index: number) => void; + onContextMenu: (index: number, e: MouseEvent) => void; } const FlowRow = memo(function FlowRow({ @@ -250,8 +308,10 @@ const FlowRow = memo(function FlowRow({ start, columns, sideBySide, + showApp, selected, onSelect, + onContextMenu, }: RowProps) { const { host, path, query } = splitUrl(flow.url); const failed = flow.error != null; @@ -263,7 +323,10 @@ const FlowRow = memo(function FlowRow({
onSelect(index)} + onMouseDown={(e) => { + if (e.button === 0) onSelect(index); + }} + onContextMenu={(e) => onContextMenu(index, e)} style={{ height: ROW_HEIGHT, transform: `translateY(${start}px)` }} className={classNames( columns, @@ -275,12 +338,24 @@ const FlowRow = memo(function FlowRow({ )} > - {failed ? ( + {flow.savedRequestId != null ? ( + + ) : failed ? ( ) : flow.responseTruncated ? ( ) : null} + {showApp && ( + + {flow.clientPath != null && } + {!sideBySide && ( + + {flow.clientName ?? --} + + )} + + )} {host} diff --git a/apps/yaak-proxy/components/ProxyLayout.tsx b/apps/yaak-proxy/components/ProxyLayout.tsx index bee892451..62efaa7e3 100644 --- a/apps/yaak-proxy/components/ProxyLayout.tsx +++ b/apps/yaak-proxy/components/ProxyLayout.tsx @@ -1,15 +1,26 @@ -import { HeaderSize, IconButton, SidebarLayout, SplitLayout } from "@yaakapp-internal/ui"; +import { + HeaderSize, + IconButton, + SidebarLayout, + SplitLayout, + type SplitLayoutLayout, + Toasts, +} from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtom, useAtomValue } from "jotai"; import { useState } from "react"; import { useLocalStorage } from "react-use"; import { flowLayoutAtom } from "../lib/atoms"; +import { searchWindowAtom, showFirstRunAtom, shownTotalAtom } from "../lib/store"; import { getOsType } from "../lib/tauri"; import { ClearFlowsButton } from "./ClearFlowsButton"; +import { FirstRun } from "./FirstRun"; import { FlowDetail } from "./FlowDetail"; import { FlowList } from "./FlowList"; import { ProxyStatus } from "./ProxyStatus"; -import { filteredCountAtom, selectedSidebarLabelAtom, Sidebar } from "./Sidebar"; +import { SaveDialog } from "./SaveDialog"; +import { selectedSidebarLabelAtom, Sidebar } from "./Sidebar"; +import { TakeOverBanner } from "./TakeOverBanner"; export function ProxyLayout() { const os = getOsType(); @@ -120,27 +131,45 @@ export function ProxyLayout() { ) } > - ( - - )} - secondSlot={({ style, orientation }) => ( - - )} - /> +
+ +
+ +
+
+ +
); } /** Its own component, so new traffic re-renders the count rather than the whole layout */ function FlowCount() { - const count = useAtomValue(filteredCountAtom); + const count = useAtomValue(shownTotalAtom); + const searching = useAtomValue(searchWindowAtom) != null; + const noun = searching ? (count === 1 ? "match" : "matches") : count === 1 ? "flow" : "flows"; + return {`${count} ${noun}`}; +} + +/** + * 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 ; return ( - {count === 1 ? "1 flow" : `${count} flows`} + ( + + )} + secondSlot={({ style, orientation }) => ( + + )} + /> ); } diff --git a/apps/yaak-proxy/components/ProxyStatus.tsx b/apps/yaak-proxy/components/ProxyStatus.tsx index d3766ff3e..fed381640 100644 --- a/apps/yaak-proxy/components/ProxyStatus.tsx +++ b/apps/yaak-proxy/components/ProxyStatus.tsx @@ -1,43 +1,135 @@ import { useQueryClient } from "@tanstack/react-query"; -import type { CaStatus } from "@yaakapp-internal/proxy-lib"; -import { Button, Dropdown, type DropdownItem, type DropdownRef, Icon } from "@yaakapp-internal/ui"; +import type { CaStatus, SystemProxyStatus } from "@yaakapp-internal/proxy-lib"; +import { + Button, + Checkbox, + Dropdown, + type DropdownItem, + type DropdownRef, + Icon, +} from "@yaakapp-internal/ui"; import classNames from "classnames"; +import { useAtomValue, useStore } from "jotai"; import type { HTMLAttributes, ReactNode, Ref } from "react"; import { useRef } from "react"; +import { useRpcEvent } from "../hooks/useRpcEvent"; import { useRpcMutation } from "../hooks/useRpcMutation"; import { useRpcQuery } from "../hooks/useRpcQuery"; -import { useRpcQueryWithEvent } from "../hooks/useRpcQueryWithEvent"; +import { environmentExports } from "../lib/env"; +import { proxyStateAtom, refreshProxyState } from "../lib/store"; +import { recordingLabel, systemProxySwitch } from "../lib/systemProxy"; +import { useShowToast } from "../lib/toast"; -/** The header's state pill and its menu, with the certificate stepping out when untrusted */ +/** + * The header's state pill and its menu: recording, the system proxy and the certificate. The + * engine runs for as long as the app does, so the pill shows whether flows are being stored. A + * certificate that is not trusted steps out of the menu beside the pill until it is. + */ export function ProxyStatus() { - const queryClient = useQueryClient(); const dropdownRef = useRef(null); - const { data: proxyState } = useRpcQueryWithEvent("get_proxy_state", {}, "proxy_state_changed"); - const caStatus = useRpcQuery("ca_status", {}); - const trust = useRpcMutation("ca_trust", { - onSuccess: (status) => queryClient.setQueryData(["ca_status", {}], status), - }); + const proxyState = useAtomValue(proxyStateAtom); + const setup = useProxySetup(); const execute = useRpcMutation("execute_action"); + const retry = useRpcMutation("take_over"); + const setAtLaunch = useSetSystemProxyAtLaunch(); + const copyEnvironment = useCopyEnvironment(); + const { caStatus, trust, systemProxy, setSystemProxy } = setup; + const systemSwitch = useSystemProxySwitch(setup); + const running = proxyState?.state === "running"; - const port = proxyState?.port; + const recording = proxyState?.recording !== false; + const other = proxyState?.otherOwner ?? null; + const port = proxyState?.port ?? other?.proxyPort ?? null; const untrusted = caStatus.data != null && !caStatus.data.trusted; + const stateRow = + other != null ? ( + } + 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 ? ( + } + 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." + } + /> + ) : ( + } + title="Not running" + description={ + + {retry.error?.message ?? proxyState?.startError ?? "The proxy has not started."} + + } + action={ + + } + /> + ); + const items: DropdownItem[] = [ + { type: "content", label: stateRow }, + { type: "separator", hidden: systemProxy.data?.supported !== true }, { type: "content", + hidden: systemProxy.data?.supported !== true, label: ( } - title={running && port != null ? `Running on port ${port}` : "Stopped"} + icon={} + title="Override system proxy" description={ - running - ? "Listening on 127.0.0.1." - : "Start the proxy to capture traffic from apps pointed at it." + + {systemSwitch.description} + + } + action={ + + setSystemProxy.mutate({ enabled })} + /> + } /> ), }, - { type: "separator" }, + { + type: "content", + hidden: systemProxy.data?.supported !== true, + label: ( + + Override system proxy at launch + } + checked={proxyState?.systemProxyAtLaunch ?? true} + disabled={setAtLaunch.isPending} + onChange={(enabled) => setAtLaunch.mutate({ enabled })} + /> + + ), + }, { type: "content", label: caStatus.data?.trusted ? ( @@ -78,13 +170,17 @@ export function ProxyStatus() { }, { type: "separator" }, { - label: running ? "Stop Proxy" : "Start Proxy", - icon: running ? "circle_stop" : "circle_play", - hotKeyAction: running ? "proxy_stop" : "proxy_start", + label: "Copy Environment for a Terminal", + icon: "square_terminal", + onSelect: copyEnvironment, + }, + { + label: recordingLabel(recording), + icon: recording ? "circle_pause" : "circle_play", + hidden: !running, + hotKeyAction: "recording_toggle", hotKeyLabelOnly: true, - onSelect: () => { - execute.mutate({ scope: "global", action: running ? "proxy_stop" : "proxy_start" }); - }, + onSelect: () => execute.mutate({ scope: "global", action: "recording_toggle" }), }, ]; @@ -97,17 +193,25 @@ export function ProxyStatus() { )} - - {running ? ( + + {other != null ? ( <> - - Running + + CLI + {other.proxyPort != null && ( + :{other.proxyPort} + )} + + ) : running ? ( + <> + + {recording ? "Recording" : "Paused"} {port != null && :{port}} ) : ( <> - - Stopped + + Not running )} @@ -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(["ca_status", {}], status), + }); + const setSystemProxy = useRpcMutation("system_proxy_set", { + mutationKey: ["system_proxy_set"], + onSuccess: (status) => { + queryClient.setQueryData(["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) { + 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 + e.stopPropagation()}> + {children} + + ); +} + +function timeOf(iso: string): string { + return new Date(iso).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); +} + interface PillProps extends HTMLAttributes { color?: "warning"; ref?: Ref; @@ -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 ( ); @@ -165,7 +348,7 @@ function MenuRow({ action?: ReactNode; }) { return ( -
+
{icon}
{title}
diff --git a/apps/yaak-proxy/components/SaveDialog.tsx b/apps/yaak-proxy/components/SaveDialog.tsx new file mode 100644 index 000000000..653632474 --- /dev/null +++ b/apps/yaak-proxy/components/SaveDialog.tsx @@ -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 ; + return ( + + {workspaces.error != null ? ( + {workspaces.error.message} + ) : workspaces.data == null ? ( + + ) : ( + + )} + + ); +} + +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("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 {saved.name || unnamed} to {workspace.name} + {folderName == null ? "." : `, in ${folderName}.`} Yaak picks it up within a second. + + ), + }); + }, + }, + ); + }; + + if (workspaces.length === 0) { + return ( + + Yaak has no workspaces yet. Create one in Yaak, then save here. + + ); + } + + if (p == null) { + return preview.error != null ? ( + {preview.error.message} + ) : ( + + ); + } + + return ( +
{ + e.preventDefault(); + submit(); + }} + > +
+ + {flow.url} +
+
+ + + + + + +
+ + {p.droppedHeaders.length > 0 && ( +
+
+ {p.droppedHeaders.length === 1 + ? "1 header left out" + : `${p.droppedHeaders.length} headers left out`}{" "} + + {droppedSummary(p.droppedHeaders.map((h) => h.reason))} + +
+
+ {p.droppedHeaders.map((h) => ( + + {h.name} + + ))} +
+
+ )} +
+ Kept + + {p.keptHeaders === 1 ? "1 header" : `${p.keptHeaders} headers`} + {p.body == null ? ", and no body" : `, and the body as ${p.body}`} + + {p.warnings.map((w) => ( + + ))} +
+ {save.error != null && {save.error.message}} + Yaak picks it up within a second + } + actions={[ + { label: "Cancel", onClick: onClose }, + { + label: "Save request", + form: SAVE_FORM_ID, + color: "primary", + isLoading: save.isPending, + disabled: workspace == null, + }, + ]} + /> + + ); +} + +function NoteRow({ warning, authorization }: { warning: string; authorization: boolean }) { + const flagged = authorization && warning.includes("Authorization"); + return ( + <> + Note + {warning} + + ); +} + +/** 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 ( + + Or keep the flow as a file} + 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) }, + ]} + /> +
+
Saving needs Yaak
+
+ 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. +
+
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + // oxlint-disable-next-line jsx-a11y/label-has-associated-control -- wraps its control + + ); +} + +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"; +} diff --git a/apps/yaak-proxy/components/SearchBar.tsx b/apps/yaak-proxy/components/SearchBar.tsx new file mode 100644 index 000000000..ed9d107f3 --- /dev/null +++ b/apps/yaak-proxy/components/SearchBar.tsx @@ -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 as the ref + const inputRef = useRef(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 ( +
+
+ { + if (e.key === "Escape" && text !== "") { + e.stopPropagation(); + clear(); + } + }} + leftSlot={ + + } + rightSlot={ + text === "" ? ( + + ⌘F + + ) : ( + + ) + } + /> +
+ { + 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" }); + } + }} + /> +
+ ); +}); diff --git a/apps/yaak-proxy/components/Sidebar.tsx b/apps/yaak-proxy/components/Sidebar.tsx index 6ac820fd6..91c3ff932 100644 --- a/apps/yaak-proxy/components/Sidebar.tsx +++ b/apps/yaak-proxy/components/Sidebar.tsx @@ -4,19 +4,22 @@ import { selectedIdsFamily, Tree } from "@yaakapp-internal/ui"; import { atom, useAtomValue } from "jotai"; import { atomWithStorage } from "jotai/utils"; import { useCallback } from "react"; +import { SIDEBAR_TREE_ID } from "../lib/atoms"; +import { appKeyOf, appNodeId, sidebarPath } from "../lib/search"; import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse"; import { flowsAtom, flowTotalAtom } from "../lib/store"; +import { AppIcon } from "./AppIcon"; -/** A node in the sidebar tree — either a domain or a path segment. */ +/** A node in the sidebar tree: a domain, a path segment, or an app. */ export type SidebarItem = { id: string; label: string; /** Flows held under this node; for the Domains folder, every flow in the database */ count: number; + /** An app's path, for its icon */ + appPath?: string; }; -export const SIDEBAR_TREE_ID = "proxy-sidebar"; - const sidebarTreeAtom = atom>((get) => { const exchanges = get(flowsAtom); return buildTree(exchanges, get(flowTotalAtom)); @@ -43,34 +46,15 @@ const collapsedAtom = atom( }, ); -/** The sidebar nodes selected, or null when the list shows all traffic */ -const selectedNodeIdsAtom = atom((get) => { - const selectedIds = get(selectedIdsFamily(SIDEBAR_TREE_ID)); - // Nothing selected, or a node holding everything → show all - if (selectedIds.length === 0 || selectedIds.includes("root") || selectedIds.includes("domains")) { - return null; - } - return new Set(selectedIds); -}); - -/** Exchanges filtered by the currently selected sidebar node(s). */ -export const filteredExchangesAtom = atom((get) => { - const exchanges = get(flowsAtom); - const selected = get(selectedNodeIdsAtom); - if (selected == null) return exchanges; - return exchanges.filter((ex) => nodeIdsOf(ex).some((id) => selected.has(id))); -}); - -/** Flows in the database for all traffic, or those held under the selected nodes */ -export const filteredCountAtom = atom((get) => - get(selectedNodeIdsAtom) == null ? get(flowTotalAtom) : get(filteredExchangesAtom).length, -); - /** The selected sidebar node's name, for the header */ export const selectedSidebarLabelAtom = atom((get) => { const selectedId = get(selectedIdsFamily(SIDEBAR_TREE_ID))[0]; if (selectedId?.startsWith("domain:")) return selectedId.slice("domain:".length); if (selectedId?.startsWith("path:")) return selectedId.slice("path:".length); + if (selectedId?.startsWith("app:")) { + const apps = get(sidebarTreeAtom).children?.find((c) => c.item.id === "apps"); + return apps?.children?.find((c) => c.item.id === selectedId)?.item.label ?? "All traffic"; + } return "All traffic"; }); @@ -83,21 +67,15 @@ const nodeIdsByFlow = new WeakMap(); function nodeIdsOf(ex: Flow): string[] { let ids = nodeIdsByFlow.get(ex); if (ids != null) return ids; - let hostname: string; - let segments: string[]; - try { - const url = new URL(ex.url); - hostname = url.host; - segments = url.pathname.split("/").filter(Boolean); - } catch { - hostname = ex.url; - segments = []; - } + // By the rules the database filters by, so a node shows the flows it counts. + const path = sidebarPath(ex.url); + const schemeless = !ex.url.includes("://"); + const [hostname = "", ...segments] = schemeless ? [path] : path.split("/"); ids = [`domain:${hostname}`]; - let path = `path:${hostname}`; + let node = `path:${hostname}`; for (const seg of segments) { - path += `/${seg}`; - ids.push(path); + node += `/${seg}`; + ids.push(node); } nodeIdsByFlow.set(ex, ids); return ids; @@ -197,6 +175,34 @@ function buildTree(exchanges: Flow[], total: number): TreeNode { 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(); + 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 = { + item: { id: "apps", label: "Apps", count: 0 }, + parent: rootNode, + depth: 1, + children: [], + draggable: false, + }; + const sortedApps = [...apps.values()].sort((a, b) => collator.compare(a.label, b.label)); + for (const item of sortedApps) { + appsFolder.children?.push({ item, parent: appsFolder, depth: 2, draggable: false }); + } + rootNode.children?.push(appsFolder); + } + rootNode.children?.push(domainsFolder); return rootNode; @@ -206,6 +212,7 @@ function ItemInner({ item }: { item: SidebarItem }) { const count = item.count; return (
+ {item.appPath != null && } {item.label} {count > 0 && {count}}
@@ -214,9 +221,9 @@ function ItemInner({ item }: { item: SidebarItem }) { export function Sidebar() { const tree = useAtomValue(sidebarTreeAtom); - const treeId = SIDEBAR_TREE_ID; const getItemKey = useCallback((item: SidebarItem) => `${item.id}:${item.count}`, []); + const treeId = SIDEBAR_TREE_ID; return ( // The tree scrolls itself, in a row of bounded height, so its virtualizer mounts only the rows diff --git a/apps/yaak-proxy/components/TakeOverBanner.tsx b/apps/yaak-proxy/components/TakeOverBanner.tsx new file mode 100644 index 000000000..fefb1b98c --- /dev/null +++ b/apps/yaak-proxy/components/TakeOverBanner.tsx @@ -0,0 +1,52 @@ +import { Button, Icon } from "@yaakapp-internal/ui"; +import { useAtomValue } from "jotai"; +import { useRpcMutation } from "../hooks/useRpcMutation"; +import { proxyStateAtom } from "../lib/store"; + +/** + * A quiet strip while another process, the CLI daemon, owns the engine: flows still list, read + * from the database, and Take over stops that process's proxy and starts this app's. Never done + * without the person asking. + */ +export function TakeOverBanner() { + const other = useAtomValue(proxyStateAtom)?.otherOwner; + const takeOver = useRpcMutation("take_over"); + if (other == null) return null; + const since = new Date(other.startedAt).toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + }); + const cli = other.owner === "cli"; + const who = cli ? "Running from the CLI" : "Running in another Yaak Proxy"; + + return ( +
+ + {who} + {other.owner === "cli" && ( + yaak proxy start + )} + + {!cli ? ( + // Stopping another app halts its engine without releasing it, so it can't be taken over. + "Another Yaak Proxy window owns the engine. Quit it to use this one." + ) : takeOver.error != null ? ( + {takeOver.error.message} + ) : ( + `pid ${other.pid}, since ${since}. Flows still list here, read from the capture database.` + )} + + {cli && ( + + )} +
+ ); +} diff --git a/apps/yaak-proxy/lib/atoms.ts b/apps/yaak-proxy/lib/atoms.ts index bfe87669e..040fbe629 100644 --- a/apps/yaak-proxy/lib/atoms.ts +++ b/apps/yaak-proxy/lib/atoms.ts @@ -3,6 +3,9 @@ import type { SplitLayoutLayout } from "@yaakapp-internal/ui"; import { atom } from "jotai"; import { atomWithStorage } from "jotai/utils"; +/** The sidebar's tree, whose selection narrows the list */ +export const SIDEBAR_TREE_ID = "proxy-sidebar"; + /** The selected flow as last seen, kept once the window has dropped it so the detail stays */ export const selectedFlowAtom = atom(null); diff --git a/apps/yaak-proxy/lib/curl.test.ts b/apps/yaak-proxy/lib/curl.test.ts new file mode 100644 index 000000000..5c04a5c23 --- /dev/null +++ b/apps/yaak-proxy/lib/curl.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "vite-plus/test"; +import { curlBodyText, shellQuote, toCurl } from "./curl"; + +const headers = (...pairs: [string, string][]) => pairs.map(([name, value]) => ({ name, value })); + +describe("toCurl", () => { + test("a GET is the URL and its headers, without the ones curl sets itself", () => { + const flow = { + method: "GET", + url: "https://api.github.com/repos/yaak?page=2", + requestHeaders: headers( + ["Host", "api.github.com"], + ["Accept", "application/json"], + ["Proxy-Connection", "keep-alive"], + ["Connection", "keep-alive"], + ), + }; + expect(toCurl(flow, null)).toBe( + "curl 'https://api.github.com/repos/yaak?page=2' \\\n -H 'Accept: application/json'", + ); + }); + + test("a POST with a body leaves the method to curl and quotes the body", () => { + const flow = { + method: "POST", + url: "https://api.stripe.com/v1/charges", + requestHeaders: headers( + ["Authorization", "Bearer sk_test_123"], + ["Content-Type", "application/json"], + ["Content-Length", "23"], + ["Accept-Encoding", "gzip, br"], + ), + }; + expect(toCurl(flow, `{"note":"it's paid"}`)).toBe( + [ + "curl 'https://api.stripe.com/v1/charges'", + "-H 'Authorization: Bearer sk_test_123'", + "-H 'Content-Type: application/json'", + "--compressed", + `--data-raw '{"note":"it'\\''s paid"}'`, + ].join(" \\\n "), + ); + }); + + test("quotes a method that is not a plain word, and drops the decoded body's encoding", () => { + const flow = { + method: "`id`", + url: "https://x.test/a", + requestHeaders: headers(["Content-Encoding", "gzip"], ["X-Trace", "1"]), + }; + expect(toCurl(flow, "plain")).toBe( + "curl -X '`id`' \\\n 'https://x.test/a' \\\n -H 'X-Trace: 1' \\\n --data-raw 'plain'", + ); + }); + + test("replays HEAD with --head and leaves brackets in the URL alone", () => { + const flow = { + method: "HEAD", + url: "https://x.test/a?filter[status]=open", + requestHeaders: [], + }; + expect(toCurl(flow, null)).toBe( + "curl --head \\\n --globoff \\\n 'https://x.test/a?filter[status]=open'", + ); + }); + + test("names any other method", () => { + const flow = { method: "DELETE", url: "https://x.test/a", requestHeaders: [] }; + expect(toCurl(flow, null)).toBe("curl -X DELETE \\\n 'https://x.test/a'"); + expect(toCurl({ ...flow, method: "GET" }, "q=1")).toBe( + "curl -X GET \\\n 'https://x.test/a' \\\n --data-raw 'q=1'", + ); + }); +}); + +describe("shellQuote", () => { + test("closes and reopens the quotes around a quote", () => { + expect(shellQuote("a'b")).toBe(`'a'\\''b'`); + expect(shellQuote("$HOME `x`")).toBe("'$HOME `x`'"); + }); +}); + +describe("curlBodyText", () => { + test("is the text of a complete UTF-8 body", () => { + expect(curlBodyText(new TextEncoder().encode('{"note":"héllo"}'), false)).toBe( + '{"note":"héllo"}', + ); + }); + + test("refuses a body curl would send different bytes for", () => { + expect(curlBodyText(new Uint8Array([0xff, 0x00, 0xfe]), false)).toBeNull(); + expect(curlBodyText(new TextEncoder().encode("the first part"), true)).toBeNull(); + }); +}); diff --git a/apps/yaak-proxy/lib/curl.ts b/apps/yaak-proxy/lib/curl.ts new file mode 100644 index 000000000..2327e42cd --- /dev/null +++ b/apps/yaak-proxy/lib/curl.ts @@ -0,0 +1,68 @@ +import type { Flow } from "@yaakapp-internal/proxy-lib"; + +/** Headers curl works out itself, or that only meant something between the app and the proxy */ +const LEFT_OUT = new Set([ + "host", + "content-length", + "connection", + "keep-alive", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +/** + * A request body as the text a curl command can carry, or null when it would not send the same + * bytes: when it is not valid UTF-8, or only its start was captured. + */ +export function curlBodyText(bytes: Uint8Array, truncated: boolean): string | null { + if (truncated) return null; + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return null; + } +} + +/** `value` in single quotes for a POSIX shell */ +export function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +/** + * The flow's request as a curl command: its method, URL, headers and `body`, the request body + * decoded. Headers curl sets itself are left out, and an encoded response is asked for with + * `--compressed`, as the app did. + */ +export function toCurl(flow: Pick, body: string | null) { + const args: string[] = []; + const hasBody = body != null && body !== ""; + const defaultMethod = hasBody ? "POST" : "GET"; + if (flow.method.toUpperCase() === "HEAD" && !hasBody) { + // So curl expects no body after the headers. + args.push("--head"); + } else if (flow.method.toUpperCase() !== defaultMethod) { + // A method is any token, which can hold characters a shell would act on. + const plain = /^[A-Za-z0-9_-]+$/.test(flow.method); + args.push(`-X ${plain ? flow.method : shellQuote(flow.method)}`); + } + // curl reads brackets and braces in a URL as ranges and sets unless told not to. + if (/[[\]{}]/.test(flow.url)) args.push("--globoff"); + args.push(shellQuote(flow.url)); + let compressed = false; + for (const { name, value } of flow.requestHeaders) { + const lower = name.toLowerCase(); + if (LEFT_OUT.has(lower) || lower.startsWith("proxy-") || lower.startsWith(":")) continue; + // The body is the decoded one, so its encoding no longer applies. + if (lower === "content-encoding" && hasBody) continue; + if (lower === "accept-encoding") { + compressed = true; + continue; + } + args.push(`-H ${shellQuote(`${name}: ${value}`)}`); + } + if (compressed) args.push("--compressed"); + if (hasBody) args.push(`--data-raw ${shellQuote(body)}`); + return `curl ${args.join(" \\\n ")}`; +} diff --git a/apps/yaak-proxy/lib/env.test.ts b/apps/yaak-proxy/lib/env.test.ts new file mode 100644 index 000000000..0bd24dd08 --- /dev/null +++ b/apps/yaak-proxy/lib/env.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "vite-plus/test"; +import { environmentExports } from "./env"; + +describe("environmentExports", () => { + test("points the proxy variables at the port and the CA variables at the certificate", () => { + const path = "/Users/me/Library/Application Support/app.yaak.proxy/ca/ca.pem"; + expect(environmentExports(9090, path)).toBe( + [ + "export HTTP_PROXY=http://127.0.0.1:9090", + "export HTTPS_PROXY=http://127.0.0.1:9090", + "export http_proxy=http://127.0.0.1:9090", + "export https_proxy=http://127.0.0.1:9090", + `export SSL_CERT_FILE='${path}'`, + `export NODE_EXTRA_CA_CERTS='${path}'`, + `export REQUESTS_CA_BUNDLE='${path}'`, + ].join("\n"), + ); + }); +}); diff --git a/apps/yaak-proxy/lib/env.ts b/apps/yaak-proxy/lib/env.ts new file mode 100644 index 000000000..1b7511fa7 --- /dev/null +++ b/apps/yaak-proxy/lib/env.ts @@ -0,0 +1,21 @@ +import { shellQuote } from "./curl"; + +/** + * Shell exports that send one terminal's traffic through the proxy on `port` and trust its CA at + * `caPath`, for tools that read neither the system proxy nor the login keychain: Node, Python, + * and anything built on OpenSSL. + */ +export function environmentExports(port: number, caPath: string): string { + const proxy = `http://127.0.0.1:${port}`; + const ca = shellQuote(caPath); + return [ + `export HTTP_PROXY=${proxy}`, + `export HTTPS_PROXY=${proxy}`, + // curl reads only the lowercase name for plain HTTP. + `export http_proxy=${proxy}`, + `export https_proxy=${proxy}`, + `export SSL_CERT_FILE=${ca}`, + `export NODE_EXTRA_CA_CERTS=${ca}`, + `export REQUESTS_CA_BUNDLE=${ca}`, + ].join("\n"); +} diff --git a/apps/yaak-proxy/lib/flowActions.ts b/apps/yaak-proxy/lib/flowActions.ts new file mode 100644 index 000000000..4c91d67fc --- /dev/null +++ b/apps/yaak-proxy/lib/flowActions.ts @@ -0,0 +1,121 @@ +import type { ExportHarRequest, Flow, FlowBody } from "@yaakapp-internal/proxy-lib"; +import type { DropdownItem } from "@yaakapp-internal/ui"; +import { save } from "@tauri-apps/plugin-dialog"; +import { atom, useSetAtom } from "jotai"; +import { useCallback } from "react"; +import { base64ToBytes } from "./body"; +import { curlBodyText, toCurl } from "./curl"; +import { rpc } from "./rpc"; +import { useShowToast } from "./toast"; + +/** The flow the save dialog is open for */ +export const saveDialogFlowAtom = atom(null); + +/** A body as text, decoding what is not UTF-8 lossily, or null when there is none */ +async function bodyText(flow: Flow, which: FlowBody): Promise { + const res = await rpc("get_flow_body", { flowId: flow.id, which }); + const bytes = base64ToBytes(res.data); + return bytes.length === 0 ? null : new TextDecoder().decode(bytes); +} + +/** + * Writes flows to a HAR file the person picks: `ids`, or every flow `filter` covers. Returns how + * many were written, or null when the save panel was cancelled. + */ +export async function exportHar( + req: Omit, + defaultName: string, +): Promise { + const path = await save({ + defaultPath: defaultName, + filters: [{ name: "HTTP Archive", extensions: ["har"] }], + }); + if (path == null) return null; + return rpc("export_har", { ...req, path }); +} + +/** What a flow's context menu and the detail pane's menu offer */ +export function useFlowActions(): (flow: Flow) => DropdownItem[] { + const showToast = useShowToast(); + const openSave = useSetAtom(saveDialogFlowAtom); + + return useCallback( + (flow: Flow) => { + const copy = async (label: string, text: () => Promise | string) => { + try { + const value = await text(); + if (value == null) { + showToast({ message: `There is no ${label} to copy`, color: "info" }); + return; + } + await navigator.clipboard.writeText(value); + showToast({ message: `Copied ${label}` }); + } catch (err) { + showToast({ message: `Failed to copy ${label}: ${String(err)}`, color: "danger" }); + } + }; + const noResponse = flow.error != null && flow.status == null; + return [ + { + label: flow.savedRequestId == null ? "Save as Yaak Request" : "Save to Yaak Again", + icon: "bookmark", + onSelect: () => openSave(flow), + }, + { type: "separator" }, + { + label: "Copy as cURL", + icon: "square_terminal", + onSelect: async () => { + let body: string | null = null; + if (flow.requestSize > 0) { + let res; + try { + res = await rpc("get_flow_body", { flowId: flow.id, which: "request" }); + } catch (err) { + showToast({ message: `Failed to copy cURL: ${String(err)}`, color: "danger" }); + return; + } + body = curlBodyText(base64ToBytes(res.data), res.truncated); + // A command that sends other bytes than the app did would mislead. + if (body == null) { + showToast({ + message: "Body is binary or truncated; export HAR instead", + color: "info", + }); + return; + } + } + await copy("cURL", () => toCurl(flow, body)); + }, + }, + { label: "Copy URL", icon: "copy", onSelect: () => copy("URL", () => flow.url) }, + { + label: "Copy Request Body", + icon: "copy", + disabled: flow.requestSize === 0, + onSelect: () => copy("request body", () => bodyText(flow, "request")), + }, + { + label: "Copy Response Body", + icon: "copy", + disabled: noResponse, + onSelect: () => copy("response body", () => bodyText(flow, "response")), + }, + { type: "separator" }, + { + label: "Export HAR", + icon: "download", + onSelect: async () => { + try { + const written = await exportHar({ ids: [flow.id] }, "flow.har"); + if (written != null) showToast({ message: "Exported the flow as HAR" }); + } catch (err) { + showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" }); + } + }, + }, + ]; + }, + [openSave, showToast], + ); +} diff --git a/apps/yaak-proxy/lib/search.test.ts b/apps/yaak-proxy/lib/search.test.ts new file mode 100644 index 000000000..5b60763f7 --- /dev/null +++ b/apps/yaak-proxy/lib/search.test.ts @@ -0,0 +1,143 @@ +import type { Flow } from "@yaakapp-internal/proxy-lib"; +import { describe, expect, test } from "vite-plus/test"; +import { matchesQuery, searchTerms, selectionQuery, sidebarPath } from "./search"; + +function flow(url: string, extra: Partial = {}): Flow { + return { + url, + method: "GET", + status: 200, + contentType: null, + clientBundleId: null, + clientPath: null, + ...extra, + } as Flow; +} + +// The same flows and terms as `search_matches_url_method_status_and_content_type` in +// `flows.rs`, so the live matcher and the database agree. +const stripe = flow("https://api.stripe.com/v1/charges?customer=cus_9", { + method: "POST", + contentType: "application/json; charset=utf-8", +}); +const github = flow("https://api.github.com/repos/yaak", { + status: 404, + contentType: "application/json; charset=utf-8", +}); +const logo = flow("https://images.ctfassets.net/logo.png", { contentType: "image/png" }); +const health = flow("http://localhost:3000/health", { method: "HEAD", status: 503 }); +const all = [stripe, github, logo, health]; + +const search = (q: string) => all.filter((f) => matchesQuery(f, { q })); + +describe("matchesQuery", () => { + test("matches the host, the path and the query, any case", () => { + expect(search("STRIPE")).toEqual([stripe]); + expect(search("/repos/")).toEqual([github]); + expect(search("cus_9")).toEqual([stripe]); + }); + + test("matches the whole method", () => { + expect(search("head")).toEqual([health]); + expect(search("ead")).toEqual([]); + }); + + test("matches a status, its start, or its class", () => { + expect(search("404")).toEqual([github]); + expect(search("50")).toEqual([health]); + expect(search("2xx")).toEqual([stripe, logo]); + }); + + test("matches the content type", () => { + expect(search("png")).toEqual([logo]); + expect(search("json")).toEqual([stripe, github]); + }); + + test("needs every term to match", () => { + expect(search("json get")).toEqual([github]); + expect(search(" json 2xx ")).toEqual([stripe]); + expect(search("json 5xx")).toEqual([]); + expect(search("")).toEqual(all); + }); + + test("keeps flows under a host or path, as the sidebar places them", () => { + const under = (node: string, url: string) => matchesQuery(flow(url), { under: [node] }); + expect(under("api.stripe.com", "https://api.stripe.com/v1/charges")).toBe(true); + expect(under("api.stripe.com", "http://api.stripe.com")).toBe(true); + // Another port is a host node of its own, as in the sidebar. + expect(under("api.stripe.com", "https://api.stripe.com:8443/v1")).toBe(false); + // Empty segments and default ports are left out, as the sidebar leaves them. + expect(under("example.com/v1/users", "https://example.com/v1//users")).toBe(true); + expect(under("x.com/a", "http://x.com:80/a")).toBe(true); + expect(under("api.stripe.com", "https://api.stripe.community/")).toBe(false); + expect(under("api.stripe.com/v1/charges", "https://api.stripe.com/v1/charges/ch_1?x")).toBe( + true, + ); + expect(under("api.stripe.com/v1/charges", "https://api.stripe.com/v1/chargesx")).toBe(false); + expect(under("api.stripe.com:8443", "https://api.stripe.com/v1")).toBe(false); + // The sidebar lowercases hosts; paths keep their case. + expect(under("example.com/A", "http://EXAMPLE.com/A/b")).toBe(true); + expect(under("example.com/a", "http://example.com/A")).toBe(false); + }); + + test("keeps flows from an app, by bundle id or path", () => { + const chrome = flow("https://example.com/", { + clientBundleId: "com.google.Chrome", + clientPath: "/Applications/Google Chrome.app", + }); + const curl = flow("https://example.com/", { clientPath: "/usr/bin/curl" }); + const unknown = flow("https://example.com/"); + const from = (app: string[]) => [chrome, curl, unknown].filter((f) => matchesQuery(f, { app })); + expect(from(["com.google.Chrome"])).toEqual([chrome]); + expect(from(["/usr/bin/curl"])).toEqual([curl]); + expect(from(["/Applications/Google Chrome.app"])).toEqual([]); + }); + + test("combines terms with the sidebar's selection", () => { + expect(matchesQuery(stripe, { q: "post", under: ["api.stripe.com"] })).toBe(true); + expect(matchesQuery(stripe, { q: "post", under: ["api.github.com"] })).toBe(false); + }); +}); + +describe("sidebarPath", () => { + // The cases `sidebar_path_rules` in `flows.rs` checks, so the two agree. + test("is the lowercased host without its default port, then the non-empty segments", () => { + for (const [url, path] of [ + ["https://example.com/v1//users", "example.com/v1/users"], + ["http://x.com:80/a", "x.com/a"], + ["https://x.com:443/a?q=1#f", "x.com/a"], + ["http://x.com:443/a", "x.com:443/a"], + ["https://API.Stripe.com:8443/", "api.stripe.com:8443"], + ["https://api.stripe.com", "api.stripe.com"], + ["https://api.stripe.com?x=/y", "api.stripe.com"], + ["http://[::1]:80/a/B", "[::1]/a/B"], + ["/api", "/api"], + ]) { + expect(sidebarPath(url ?? ""), url).toBe(path); + } + }); +}); + +describe("selectionQuery", () => { + test("is null for nothing selected or a node holding every flow", () => { + expect(selectionQuery([])).toBeNull(); + expect(selectionQuery(["root"])).toBeNull(); + expect(selectionQuery(["domain:a.com", "domains"])).toBeNull(); + expect(selectionQuery(["apps"])).toBeNull(); + }); + + test("splits hosts and paths from apps", () => { + expect(selectionQuery(["domain:a.com", "path:b.com/v1", "app:com.apple.Safari"])).toEqual({ + under: ["a.com", "b.com/v1"], + app: ["com.apple.Safari"], + }); + expect(selectionQuery(["app:/usr/bin/curl"])).toEqual({ app: ["/usr/bin/curl"] }); + }); +}); + +describe("searchTerms", () => { + test("trims and collapses the box's text, or is null without any", () => { + expect(searchTerms(" stripe post ")).toBe("stripe post"); + expect(searchTerms(" ")).toBeNull(); + }); +}); diff --git a/apps/yaak-proxy/lib/search.ts b/apps/yaak-proxy/lib/search.ts new file mode 100644 index 000000000..4260b58c9 --- /dev/null +++ b/apps/yaak-proxy/lib/search.ts @@ -0,0 +1,116 @@ +import type { Flow, FlowFilter } from "@yaakapp-internal/proxy-lib"; + +/** + * A search over every flow in the database: the search box's terms and the sidebar's selection, + * as `list_flows` takes them. + */ +export type FlowQuery = Pick; + +/** The sidebar node for all of an app's flows */ +export function appNodeId(key: string): string { + return `app:${key}`; +} + +/** What groups an app's flows: its bundle id, or its path for a process outside an app */ +export function appKeyOf(flow: Flow): string | null { + return flow.clientBundleId ?? flow.clientPath; +} + +/** + * The sidebar's selection as a query: hosts and paths under `under`, apps under `app`. Null when + * the selection holds every flow. + */ +export function selectionQuery(selectedIds: string[]): FlowQuery | null { + const under: string[] = []; + const app: string[] = []; + for (const id of selectedIds) { + if (id === "root" || id === "domains" || id === "apps") return null; + if (id.startsWith("domain:")) under.push(id.slice("domain:".length)); + else if (id.startsWith("path:")) under.push(id.slice("path:".length)); + else if (id.startsWith("app:")) app.push(id.slice("app:".length)); + } + if (under.length === 0 && app.length === 0) return null; + return { + ...(under.length > 0 ? { under } : {}), + ...(app.length > 0 ? { app } : {}), + }; +} + +/** + * Whether `flow` is one `query` covers, as `list_flows` decides it in SQL, so new traffic joins a + * search as it arrives. Every term must match the URL, the method, the status or the content type. + */ +export function matchesQuery(flow: Flow, query: FlowQuery): boolean { + for (const term of (query.q ?? "").toLowerCase().split(/\s+/)) { + if (term === "") continue; + const matched = + flow.url.toLowerCase().includes(term) || + flow.method.toLowerCase() === term || + (flow.contentType?.toLowerCase().includes(term) ?? false) || + matchesStatus(flow.status, term); + if (!matched) return false; + } + if (query.under != null && !query.under.some((node) => isUnder(flow.url, node))) return false; + if (query.app != null) { + const key = appKeyOf(flow); + if (key == null || !query.app.includes(key)) return false; + } + return true; +} + +/** `404` exactly, `40` as the start of one, or a class such as `4xx` */ +function matchesStatus(status: number | null, term: string): boolean { + if (status == null) return false; + const klass = /^([1-9])xx$/.exec(term); + if (klass != null) return Math.trunc(status / 100) === Number(klass[1]); + if (!/^\d{1,3}$/.test(term)) return false; + return term.length === 3 ? status === Number(term) : String(status).startsWith(term); +} + +/** The URL is the host or path `node` names, or something below it, by the sidebar's rules */ +function isUnder(url: string, node: string): boolean { + const path = sidebarPath(url); + const want = normalizeNode(node); + return path === want || path.startsWith(`${want}/`); +} + +/** + * Where the sidebar places a URL: its host, lowercased and without its scheme's default port, + * then its path's non-empty segments, joined by `/`, as in `api.stripe.com/v1/charges`. A URL + * without a scheme is a host of its own. `sidebar_path` in `crates-proxy/yaak-proxy-lib`, which + * the database filters by, follows the same rules. + */ +export function sidebarPath(url: string): string { + const schemeEnd = url.indexOf("://"); + if (schemeEnd === -1) return url; + const scheme = url.slice(0, schemeEnd).toLowerCase(); + const rest = url.slice(schemeEnd + 3); + const authorityEnd = rest.search(/[/?#]/); + let host = (authorityEnd === -1 ? rest : rest.slice(0, authorityEnd)).toLowerCase(); + const defaultPort = + scheme === "http" || scheme === "ws" + ? ":80" + : scheme === "https" || scheme === "wss" + ? ":443" + : null; + if (defaultPort != null && host.endsWith(defaultPort)) { + host = host.slice(0, -defaultPort.length); + } + const path = authorityEnd === -1 ? "" : (rest.slice(authorityEnd).split(/[?#]/)[0] ?? ""); + const segments = path.split("/").filter((s) => s !== ""); + return [host, ...segments].join("/"); +} + +/** A sidebar node as `sidebarPath` writes it, its host lowercased */ +function normalizeNode(node: string): string { + const slash = node.indexOf("/"); + return slash === -1 + ? node.toLowerCase() + : `${node.slice(0, slash).toLowerCase()}${node.slice(slash)}`; +} + +/** The search box's text as terms for `list_flows`, or null when it has none */ +export function searchTerms(text: string): string | null { + const q = text.trim().replace(/\s+/g, " "); + return q === "" ? null : q; +} diff --git a/apps/yaak-proxy/lib/store.ts b/apps/yaak-proxy/lib/store.ts index ff317dac9..90e8d25d1 100644 --- a/apps/yaak-proxy/lib/store.ts +++ b/apps/yaak-proxy/lib/store.ts @@ -1,6 +1,7 @@ -import type { ModelPayload } from "@yaakapp-internal/proxy-lib"; +import type { GetProxyStateResponse, ModelPayload } from "@yaakapp-internal/proxy-lib"; +import { selectedIdsFamily } from "@yaakapp-internal/ui"; import { atom, type createStore } from "jotai"; -import { followingAtom, selectedFlowAtom } from "./atoms"; +import { followingAtom, SIDEBAR_TREE_ID, selectedFlowAtom } from "./atoms"; import { applyChanges, emptyWindow, @@ -12,11 +13,18 @@ import { windowFromPage, } from "./flowWindow"; import { listen, rpc } from "./rpc"; +import { type FlowQuery, matchesQuery, selectionQuery } from "./search"; + +type Store = ReturnType; /** How long new flows gather before they reach the list, so a burst renders once */ const FLUSH_MS = 100; /** How long after new flows the total is counted again from the database */ const RECOUNT_MS = 1000; +/** How often the database is read for new flows while another process owns the engine */ +const POLL_MS = 1000; +/** How often the engine's owner is checked while it is another process */ +const OWNER_POLL_MS = 2000; /** The flows in memory: a window onto the database, which keeps them all */ export const flowWindowAtom = atom(emptyWindow); @@ -27,23 +35,73 @@ export const flowsAtom = atom((get) => get(flowWindowAtom).flows); /** Start of the newest flow held, across all traffic */ export const newestStartedAtAtom = atom((get) => get(flowsAtom).at(-1)?.startedAt ?? null); -/** Start and id of the oldest flow held, which loading older flows moves back */ -export const oldestFlowAtom = atom((get) => get(flowsAtom)[0] ?? null); - -export const hasOlderFlowsAtom = atom((get) => get(flowWindowAtom).hasOlder); - /** Flows in the database */ export const flowTotalAtom = atom((get) => get(flowWindowAtom).total); +/** The first page of flows has loaded, so an empty window means an empty database */ +export const flowsLoadedAtom = atom(false); + +/** + * The database has no flows and nothing narrows the list, so the first-run card shows. A boolean + * of its own, so new traffic does not re-render what reads it. + */ +export const showFirstRunAtom = atom( + (get) => get(flowsLoadedAtom) && get(flowTotalAtom) === 0 && get(activeQueryAtom) == null, +); + +/** Some flow held names the app that sent it, which only some platforms can tell */ +export const hasAppsAtom = atom((get) => get(flowsAtom).some((f) => f.clientName != null)); + +/** The search box's terms, once typing settles, or null */ +export const searchTermsAtom = atom(null); + +/** The search the list shows, from the search box and the sidebar, or null for all traffic */ +export const activeQueryAtom = atom((get) => { + const q = get(searchTermsAtom); + const selection = selectionQuery(get(selectedIdsFamily(SIDEBAR_TREE_ID))); + if (q == null && selection == null) return null; + return { ...(q == null ? {} : { q }), ...selection }; +}); + +/** A window onto the flows a search covers, loaded from the database as the main one is */ +export interface SearchWindow { + key: string; + query: FlowQuery; + win: FlowWindow; + /** The first page has not arrived yet */ + loading: boolean; + /** Changes that came while the first page loaded, applied once it is in */ + buffered: ModelPayload[]; +} + +export const searchWindowAtom = atom(null); + +/** The window the list shows: the search's, or all traffic's */ +const shownWindowAtom = atom((get) => get(searchWindowAtom)?.win ?? get(flowWindowAtom)); + +/** The flows the list shows, oldest first */ +export const shownFlowsAtom = atom((get) => get(shownWindowAtom).flows); + +/** Flows in the database the list covers: every one, or those the search matches */ +export const shownTotalAtom = atom((get) => get(shownWindowAtom).total); + +/** Start and id of the oldest flow shown, which loading older flows moves back */ +export const oldestFlowAtom = atom((get) => get(shownFlowsAtom)[0] ?? null); + /** Follows new traffic, dropping the older flows scrolling up loaded, or holds the list */ export const followAtom = atom( (get) => get(followingAtom), (_get, set, following: boolean) => { set(followingAtom, following); - if (following) set(flowWindowAtom, (win) => trimWindow(win)); + if (!following) return; + set(flowWindowAtom, (win) => trimWindow(win)); + set(searchWindowAtom, (s) => (s == null ? s : { ...s, win: trimWindow(s.win) })); }, ); +/** The engine's state, kept current from its events, and polled while another process has it */ +export const proxyStateAtom = atom(null); + /** * Changes applied while a page of older flows loads, or null when none is loading. A slow flow * that ends meanwhile may be older than the window, so left out of it, and missing from a page @@ -51,22 +109,29 @@ export const followAtom = atom( */ let changesDuringPage: ModelPayload[] | null = null; -/** Loads the page of flows before the oldest one held */ +/** Loads the page of flows before the oldest one shown, from the search's flows when searching */ export const loadOlderFlowsAtom = atom(null, async (get, set) => { - const { flows, hasOlder } = get(flowWindowAtom); + const search = get(searchWindowAtom); + const { flows, hasOlder } = search?.win ?? get(flowWindowAtom); const first = flows[0]; if (changesDuringPage != null || !hasOlder || first == null) return; const changes: ModelPayload[] = []; changesDuringPage = changes; try { - const res = await rpc("list_flows", { before: first.id, limit: PAGE_SIZE }); - // Dropped when the window moved on meanwhile, trimmed or cleared. - set(flowWindowAtom, (win) => { + const res = await rpc("list_flows", { ...search?.query, before: first.id, limit: PAGE_SIZE }); + // Dropped when the window moved on meanwhile, trimmed, cleared or searched again. + const merge = (win: FlowWindow) => { if (win.flows[0]?.id !== first.id) return win; const merged = prependOlder(win, res.flows, PAGE_SIZE); + const kept = search == null ? changes : changesFor(search.query, changes); // Counted when they first applied. - return { ...applyChanges(merged, changes), total: merged.total }; - }); + return { ...applyChanges(merged, kept), total: merged.total }; + }; + if (search == null) { + set(flowWindowAtom, merge); + } else { + set(searchWindowAtom, (s) => (s?.key === search.key ? { ...s, win: merge(s.win) } : s)); + } } catch (err) { console.warn("Failed to load older flows", err); } finally { @@ -74,16 +139,26 @@ export const loadOlderFlowsAtom = atom(null, async (get, set) => { } }); +/** The changes a search's window takes: flows it matches, and every deletion */ +function changesFor(query: FlowQuery, changes: ModelPayload[]): ModelPayload[] { + return changes.filter((c) => c.change.type === "delete" || matchesQuery(c.model, query)); +} + /** - * Loads the newest flows, then keeps them current with the engine's events. Events gather for - * `FLUSH_MS` and apply together, and the window drops its oldest flows while the list follows, - * so a new flow costs the same however many the database holds. + * Loads the newest flows, then keeps them current with the engine's events, or with reads of the + * database while another process owns the engine. Events gather for `FLUSH_MS` and apply + * together, and the window drops its oldest flows while the list follows, so a new flow costs the + * same however many the database holds. A search gets a window of its own, kept current the same + * way. */ -export function syncFlows(store: ReturnType) { +export function syncFlows(store: Store) { let pending: ModelPayload[] = []; let timer: ReturnType | null = null; let loaded = false; let loads = 0; + let searchLoads = 0; + /** A flow written at or before the newest the app has seen, for reading new ones after it */ + let seenId: string | null = null; function flush() { timer = null; @@ -91,9 +166,17 @@ export function syncFlows(store: ReturnType) { const changes = pending; pending = []; changesDuringPage?.push(...changes); + const following = store.get(followingAtom); store.set(flowWindowAtom, (win) => { const next = applyChanges(win, changes); - return store.get(followingAtom) ? trimWindow(next) : next; + return following ? trimWindow(next) : next; + }); + store.set(searchWindowAtom, (s) => { + if (s == null) return s; + // A matching flow written after the page's snapshot would otherwise be missed. + if (s.loading) return { ...s, buffered: [...s.buffered, ...changes] }; + const next = applyChanges(s.win, changesFor(s.query, changes)); + return { ...s, win: following ? trimWindow(next) : next }; }); const selected = store.get(selectedFlowAtom); const change = changes.findLast((c) => c.model.id === selected?.id); @@ -103,6 +186,11 @@ export function syncFlows(store: ReturnType) { if (recountTimer == null) recountTimer = setTimeout(recount, RECOUNT_MS); } + function receive(changes: ModelPayload[]) { + pending.push(...changes); + if (timer == null) timer = setTimeout(flush, FLUSH_MS); + } + // The total counts each new flow as it comes, which a flow both in a page and in the events // around it can count twice or not at all; a count from the database a moment later settles it. let recountTimer: ReturnType | null = null; @@ -115,11 +203,19 @@ export function syncFlows(store: ReturnType) { win.total === res.total ? win : { ...win, total: res.total }, ); }); + const search = store.get(searchWindowAtom); + if (search == null || search.loading) return; + void rpc("list_flows", { ...search.query, limit: 0 }).then((res) => { + const total = res.matching ?? res.total; + store.set(searchWindowAtom, (s) => + s?.key !== search.key || s.win.total === total ? s : { ...s, win: { ...s.win, total } }, + ); + }); } listen("model_write", (payload) => { - pending.push(payload); - if (timer == null) timer = setTimeout(flush, FLUSH_MS); + seenId = payload.model.id; + receive([payload]); }); // Loads again rather than only emptying, for a flow written between the delete and the event. @@ -138,10 +234,123 @@ export function syncFlows(store: ReturnType) { // A clear since asked for a newer page. if (thisLoad !== loads) return; store.set(flowWindowAtom, windowFromPage(res.flows, WINDOW_SIZE, res.total)); + seenId = res.flows[0]?.id ?? null; loaded = true; + store.set(flowsLoadedAtom, true); flush(); }); + loadSearch(store.get(activeQueryAtom)); } + /** Loads the first page of `query`'s flows into a window of its own, or drops it for null */ + function loadSearch(query: FlowQuery | null) { + if (query == null) { + store.set(searchWindowAtom, null); + return; + } + // Unique to this load, so a slower answer to an earlier one with the same query, such as + // one read before a clear, is dropped. + const key = `${++searchLoads}:${JSON.stringify(query)}`; + store.set(searchWindowAtom, { key, query, win: emptyWindow, loading: true, buffered: [] }); + const settle = (page: FlowWindow | null) => + store.set(searchWindowAtom, (s) => { + if (s?.key !== key) return s; + // Applied again over the page: a flow already in it is replaced, not added. + const win = applyChanges(page ?? s.win, changesFor(query, s.buffered)); + return { + ...s, + win: page == null ? win : { ...win, total: page.total }, + loading: false, + buffered: [], + }; + }); + void rpc("list_flows", { ...query, limit: WINDOW_SIZE }) + .then((res) => settle(windowFromPage(res.flows, WINDOW_SIZE, res.matching ?? res.total))) + .catch((err) => { + console.warn("Failed to search flows", err); + settle(null); + }); + } + + let activeKey = JSON.stringify(store.get(activeQueryAtom)); + store.sub(activeQueryAtom, () => { + const query = store.get(activeQueryAtom); + const key = JSON.stringify(query); + if (key === activeKey) return; + activeKey = key; + loadSearch(query); + }); + + // While another process owns the engine no events come, so new flows are read from the + // database. Reading after a flow that started last can return a slow one already seen, which + // applies as an update; it never skips one. + let polling = false; + function readNewFlows() { + if (polling || !loaded) return; + polling = true; + const after = seenId; + const read = + after == null + ? rpc("list_flows", { limit: WINDOW_SIZE }) + : rpc("list_flows", { after, limit: WINDOW_SIZE }); + read + .then((res) => { + if (res.flows.length === 0) return; + // More arrived than one read holds: load the newest again rather than leave a gap. + if (res.flows.length >= WINDOW_SIZE) { + load(); + return; + } + if (seenId === after) seenId = res.flows[0]?.id ?? seenId; + const known = new Set(store.get(flowsAtom).map((f) => f.id)); + receive( + [...res.flows].reverse().map((model) => ({ + model, + change: { type: "upsert", created: !known.has(model.id) }, + })), + ); + }) + // The flow read after was cleared: start again from the newest. + .catch(() => { + seenId = null; + load(); + }) + .finally(() => { + polling = false; + }); + } + setInterval(() => { + if (store.get(proxyStateAtom)?.otherOwner != null) readNewFlows(); + }, POLL_MS); + // One last read once another owner lets go, for flows it wrote after the last one. + let hadOtherOwner = store.get(proxyStateAtom)?.otherOwner != null; + store.sub(proxyStateAtom, () => { + const hasOtherOwner = store.get(proxyStateAtom)?.otherOwner != null; + if (hadOtherOwner && !hasOtherOwner) readNewFlows(); + hadOtherOwner = hasOtherOwner; + }); + load(); } + +/** + * Keeps `proxyStateAtom` current: on each state change, and polled while the engine is not + * running here, so another process taking it or letting it go shows. + */ +export function syncProxyState(store: Store) { + function refresh() { + rpc("get_proxy_state", {}) + .then((state) => store.set(proxyStateAtom, state)) + .catch((err) => console.warn("Failed to read the proxy state", err)); + } + listen("proxy_state_changed", refresh); + setInterval(() => { + if (store.get(proxyStateAtom)?.state !== "running") refresh(); + }, OWNER_POLL_MS); + refresh(); +} + +/** Reads the proxy state again, after an action that changes it without an event */ +export async function refreshProxyState(store: Store) { + store.set(proxyStateAtom, await rpc("get_proxy_state", {})); +} diff --git a/apps/yaak-proxy/lib/systemProxy.test.ts b/apps/yaak-proxy/lib/systemProxy.test.ts new file mode 100644 index 000000000..ab9a6e3d8 --- /dev/null +++ b/apps/yaak-proxy/lib/systemProxy.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "vite-plus/test"; +import { recordingLabel, type SystemProxyState, systemProxySwitch } from "./systemProxy"; + +const off: SystemProxyState = { routing: false, port: 9090, pending: null, error: null }; + +describe("systemProxySwitch", () => { + test("says whether this Mac's traffic is routed now", () => { + expect(systemProxySwitch({ ...off, routing: true })).toEqual({ + checked: true, + description: "Routing this Mac's web traffic through Yaak Proxy", + failed: false, + }); + expect(systemProxySwitch(off)).toEqual({ + checked: false, + description: "Not routing. Only apps pointed at port 9090 are captured", + failed: false, + }); + }); + + test("shows the new value while the password prompt is up", () => { + expect(systemProxySwitch({ ...off, pending: true })).toEqual({ + checked: true, + description: "Waiting for your password", + failed: false, + }); + }); + + test("snaps back on a cancelled prompt and says so", () => { + expect(systemProxySwitch({ ...off, error: "Cancelled in the password dialog" })).toEqual({ + checked: false, + description: "Cancelled in the password prompt", + failed: true, + }); + expect(systemProxySwitch({ ...off, error: "networksetup failed" }).description).toBe( + "networksetup failed", + ); + }); +}); + +describe("recordingLabel", () => { + test("offers the opposite of the current state", () => { + expect(recordingLabel(true)).toBe("Pause Recording"); + expect(recordingLabel(false)).toBe("Resume Recording"); + }); +}); diff --git a/apps/yaak-proxy/lib/systemProxy.ts b/apps/yaak-proxy/lib/systemProxy.ts new file mode 100644 index 000000000..031fad9b7 --- /dev/null +++ b/apps/yaak-proxy/lib/systemProxy.ts @@ -0,0 +1,51 @@ +/** + * "Override system proxy": this Mac's web traffic pointed at the running engine, changed at once + * whenever it is flipped, through the macOS password prompt. Whether it turns on as the app + * launches is a separate setting. + */ +export interface SystemProxyState { + /** This Mac's web traffic points at the engine now */ + routing: boolean; + /** The port apps can be pointed at by hand */ + port: number | null; + /** A change is in flight, to this value */ + pending: boolean | null; + /** The last change failed, with this message */ + error: string | null; +} + +export interface SystemProxySwitch { + checked: boolean; + description: string; + /** The description reports a failure */ + failed: boolean; +} + +/** What the system proxy switch shows */ +export function systemProxySwitch(state: SystemProxyState): SystemProxySwitch { + if (state.pending != null) { + return { checked: state.pending, description: "Waiting for your password", failed: false }; + } + // A failed change leaves the switch where the system proxy actually is. + const checked = state.routing; + if (state.error != null) { + const cancelled = /cancel/i.test(state.error); + return { + checked, + description: cancelled ? "Cancelled in the password prompt" : state.error, + failed: true, + }; + } + return { + checked, + description: checked + ? "Routing this Mac's web traffic through Yaak Proxy" + : `Not routing. Only apps pointed at port ${state.port ?? 9090} are captured`, + failed: false, + }; +} + +/** The menu item that pauses or resumes storing flows */ +export function recordingLabel(recording: boolean): string { + return recording ? "Pause Recording" : "Resume Recording"; +} diff --git a/apps/yaak-proxy/lib/toast.ts b/apps/yaak-proxy/lib/toast.ts new file mode 100644 index 000000000..130e05ac6 --- /dev/null +++ b/apps/yaak-proxy/lib/toast.ts @@ -0,0 +1,12 @@ +import { type ShowToastArgs, showToast } from "@yaakapp-internal/ui"; +import { useStore } from "jotai"; +import { useCallback } from "react"; + +/** Shows one of the client's toasts from this app's store, a success unless it says otherwise */ +export function useShowToast() { + const store = useStore(); + return useCallback( + (toast: ShowToastArgs) => showToast(store, { color: "success", ...toast }), + [store], + ); +} diff --git a/apps/yaak-proxy/main.tsx b/apps/yaak-proxy/main.tsx index 246567db2..b34daffa3 100644 --- a/apps/yaak-proxy/main.tsx +++ b/apps/yaak-proxy/main.tsx @@ -6,12 +6,13 @@ import { type ReactNode, StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { ProxyLayout } from "./components/ProxyLayout"; import { useHotkeyContextValue } from "./lib/hotkeys"; -import { syncFlows } from "./lib/store"; +import { syncFlows, syncProxyState } from "./lib/store"; import "./main.css"; const queryClient = new QueryClient(); const jotaiStore = createStore(); +syncProxyState(jotaiStore); syncFlows(jotaiStore); function Hotkeys({ children }: { children: ReactNode }) { diff --git a/apps/yaak-proxy/package.json b/apps/yaak-proxy/package.json index 3c3406794..65aa4f80b 100644 --- a/apps/yaak-proxy/package.json +++ b/apps/yaak-proxy/package.json @@ -12,6 +12,8 @@ "@tanstack/react-query": "^5.104.2", "@tanstack/react-virtual": "^3.14.17", "@tauri-apps/api": "^2.12.3", + "@tauri-apps/plugin-dialog": "^2.8.2", + "@tauri-apps/plugin-opener": "^2.7.1", "@tauri-apps/plugin-os": "^2.4.0", "@yaakapp-internal/proxy-lib": "^1.0.0", "@yaakapp-internal/theme": "^1.0.0", diff --git a/crates-proxy/yaak-proxy-lib/Cargo.toml b/crates-proxy/yaak-proxy-lib/Cargo.toml index 6bfa3b9e6..d0bf5a144 100644 --- a/crates-proxy/yaak-proxy-lib/Cargo.toml +++ b/crates-proxy/yaak-proxy-lib/Cargo.toml @@ -17,7 +17,7 @@ include_dir = "0.7" r2d2 = "0.8.10" r2d2_sqlite = "0.32" reqwest = { workspace = true, features = ["json"] } -rusqlite = { version = "0.38", features = ["bundled", "chrono"] } +rusqlite = { version = "0.38", features = ["bundled", "chrono", "functions"] } sea-query = { version = "1.0", features = ["with-chrono", "attr"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } @@ -37,6 +37,27 @@ yaak-templates = { workspace = true } [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = { version = "0.3.2", default-features = false, features = [ + "std", + "objc2-core-foundation", + "NSBitmapImageRep", + "NSGraphics", + "NSGraphicsContext", + "NSImage", + "NSImageRep", + "NSWorkspace", +] } +objc2-foundation = { version = "0.3.2", default-features = false, features = [ + "std", + "NSData", + "NSDictionary", + "NSGeometry", + "NSString", +] } +plist = "1.7" + [dev-dependencies] base64 = "0.23.1" bytes = "1" diff --git a/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts b/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts index 0fca90b9b..5f8202d2a 100644 --- a/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts +++ b/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts @@ -44,7 +44,23 @@ elapsedMs: number | null, error: string | null, /** * The id of the Yaak request this flow was last saved as. */ -savedRequestId: string | null, }; +savedRequestId: string | null, +/** + * The process that opened the client connection, where the platform can tell (macOS). + */ +clientPid: number | null, +/** + * Its app's name, or its executable's outside an app: "Google Chrome", "curl". + */ +clientName: string | null, +/** + * Its app's bundle id. + */ +clientBundleId: string | null, +/** + * Its app bundle's path, or its executable's outside an app. + */ +clientPath: string | null, }; export type FlowBody = "request" | "response"; diff --git a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts index af32c1648..07e1a4a7f 100644 --- a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts +++ b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts @@ -5,6 +5,12 @@ export type ActionInvocation = { "scope": "global", action: GlobalAction, }; export type ActionMetadata = { label: string, defaultHotkey: string | null, }; +export type AppIconRequest = { +/** + * A flow's `clientPath`. + */ +path: string, }; + export type CaStatus = { /** * macOS trusts this app's CA, so HTTPS flows can be decrypted. @@ -21,6 +27,37 @@ export type CaTrustRequest = Record; export type ClearFlowsRequest = Record; +/** + * The app behind a client connection. + */ +export type ClientApp = { pid: number, +/** + * The app's name ("Google Chrome"), or the executable's for a process outside an app ("curl"). + */ +name: string, +/** + * The outermost `.app` bundle's CFBundleIdentifier, if any. + */ +bundleId: string | null, +/** + * The outermost `.app` bundle's path, or the executable's path when there is no bundle. + */ +path: string, }; + +export type DropReason = "hop_by_hop" | "set_by_sending" | "body_decoded"; + +export type DroppedHeader = { name: string, value: string, reason: DropReason, }; + +export type ExportHarRequest = { +/** + * The file to write. + */ +path: string, +/** + * These flows, or else every flow the filter covers. + */ +ids?: Array, filter?: FlowFilter, }; + export type FilterBodyRequest = { flowId: string, which: FlowBody, /** * JSONPath, or XPath for an XML or HTML body. @@ -39,10 +76,25 @@ export type FilterLanguage = "jsonpath" | "xpath"; * Which flows a listing or a HAR export covers. Every field narrows it; none is all of them. */ export type FlowFilter = { +/** + * Search terms, any case, each of which must match the URL (host, path or query), the + * method, the status (`404`, `40`, `4xx`) or the content type: `stripe post 4xx`. + */ +q?: string, /** * Part of the host, any case: `stripe` matches `api.stripe.com`. */ host?: string, +/** + * Only flows under one of these hosts or paths, as the app's sidebar names them: + * `api.stripe.com` or `api.stripe.com/v1/charges`, with any port after the host. + */ +under?: Array, +/** + * Only flows from one of these apps, by bundle id, or by path for a process outside an + * app bundle. + */ +app?: Array, /** * The method, any case. */ @@ -68,6 +120,12 @@ before?: string, }; export type FlowsClearedPayload = Record; +export type FolderSummary = { id: string, name: string, +/** + * The folder this one is in, if any. + */ +folderId: string | null, }; + export type FormatBodyRequest = { text: string, }; export type GetFlowBodyRequest = { flowId: string, which: FlowBody, }; @@ -92,9 +150,30 @@ export type GetProxyStateResponse = { state: ProxyState, /** * The port the engine is bound to, while it runs. */ -port: number | null, }; +port: number | null, +/** + * The port the engine starts on. + */ +configuredPort: number, +/** + * Flows are stored. Paused, traffic still passes through, unrecorded. + */ +recording: boolean, +/** + * The system proxy turns on as the engine starts. + */ +systemProxyAtLaunch: boolean, +/** + * Why the engine last failed to start, until it starts. + */ +startError: string | null, +/** + * Another process that owns the engine, such as the CLI daemon. While one does, this app + * cannot start its own, and new flows reach it only through the database. + */ +otherOwner: OtherOwner | null, }; -export type GlobalAction = "proxy_start" | "proxy_stop" | "flows_clear"; +export type GlobalAction = "recording_toggle" | "flows_clear"; export type ListActionsRequest = Record; @@ -108,13 +187,33 @@ flows: Array, /** * Flows in the database, whatever the filter. */ -total: number, }; +total: number, +/** + * Flows the filter covers, whatever the limit, when it narrows to fewer than all of them. + */ +matching: number | null, }; + +export type ListWorkspacesRequest = Record; + +export type ListWorkspacesResponse = { +/** + * Yaak has run on this machine. Without it there is nothing to save into. + */ +yaakFound: boolean, workspaces: Array, }; + +export type OtherOwner = { owner: Owner, pid: number, startedAt: string, +/** + * The engine's port while it runs. + */ +proxyPort: number | null, }; /** * The process that runs the engine. */ export type Owner = "app" | "cli"; +export type PreviewSaveRequest = { flowId: string, }; + export type ProxySetting = { enabled: boolean, server: string, port: number, }; export type ProxyState = "running" | "stopped"; @@ -123,7 +222,49 @@ export type ProxyStatePayload = { state: ProxyState, }; export type RpcEventSchema = { flows_cleared: FlowsClearedPayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, }; -export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], }; +export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], save_flow: [SaveFlowToYaakRequest, SavedFlow], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], }; + +export type SaveFlowToYaakRequest = { flowId: string, workspaceId: string, folderId?: string, +/** + * None or blank leaves the request unnamed, and Yaak shows its URL. + */ +name?: string, }; + +/** + * What saving a flow as a Yaak request writes, for the save dialog. + */ +export type SavePreview = { method: string, url: string, +/** + * The URL as saved, its query split out into parameters, which Yaak shows for an unnamed + * request. + */ +requestUrl: string, +/** + * The saved body type, which Yaak shows for an unnamed GraphQL request with no URL. + */ +bodyType: string | null, +/** + * Headers Yaak sets itself or that only meant something to the proxy. + */ +droppedHeaders: Array, +/** + * Headers saved as captured, Authorization among them. + */ +keptHeaders: number, +/** + * The body as it is saved: "JSON", "form URL-encoded with 7 fields". None without a body. + */ +body: string | null, +/** + * What does not carry over exactly, one sentence each. + */ +warnings: Array, +/** + * An Authorization header is saved in plain text. + */ +authorization: boolean, }; + +export type SavedFlow = { requestId: string, name: string, }; export type ServiceProxy = { /** @@ -131,6 +272,10 @@ export type ServiceProxy = { */ name: string, http: ProxySetting, https: ProxySetting, }; +export type SetRecordingRequest = { enabled: boolean, }; + +export type SetSystemProxyAtLaunchRequest = { enabled: boolean, }; + /** * How a change gets administrator rights. */ @@ -153,3 +298,7 @@ port: number, services: Array, backend: SystemProxyBackend, supported: boolean, }; export type SystemProxyStatusRequest = Record; + +export type TakeOverRequest = Record; + +export type WorkspaceSummary = { id: string, name: string, folders: Array, }; diff --git a/crates-proxy/yaak-proxy-lib/migrations/00000004_flow_client.sql b/crates-proxy/yaak-proxy-lib/migrations/00000004_flow_client.sql new file mode 100644 index 000000000..2ca9dcfae --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/migrations/00000004_flow_client.sql @@ -0,0 +1,9 @@ +-- The app that opened the flow's client connection, where the platform can tell. +ALTER TABLE flows ADD COLUMN client_pid INTEGER; +ALTER TABLE flows ADD COLUMN client_name TEXT; +ALTER TABLE flows ADD COLUMN client_bundle_id TEXT; +ALTER TABLE flows ADD COLUMN client_path TEXT; + +-- Flows list and page newest first, by start and then id. +DROP INDEX idx_flows_started_at; +CREATE INDEX idx_flows_started_at_id ON flows (started_at, id); diff --git a/crates-proxy/yaak-proxy-lib/src/actions.rs b/crates-proxy/yaak-proxy-lib/src/actions.rs index e00f8a608..4aba47644 100644 --- a/crates-proxy/yaak-proxy-lib/src/actions.rs +++ b/crates-proxy/yaak-proxy-lib/src/actions.rs @@ -5,8 +5,8 @@ use ts_rs::TS; #[serde(rename_all = "snake_case")] #[ts(export, export_to = "gen_rpc.ts")] pub enum GlobalAction { - ProxyStart, - ProxyStop, + /// Pauses or resumes storing flows. + RecordingToggle, FlowsClear, } @@ -33,17 +33,11 @@ fn default_hotkey(mac: &str, other: &str) -> Option { pub fn all_global_actions() -> Vec<(ActionInvocation, ActionMetadata)> { vec![ ( - ActionInvocation::Global { action: GlobalAction::ProxyStart }, + ActionInvocation::Global { action: GlobalAction::RecordingToggle }, ActionMetadata { - label: "Start Proxy".into(), - default_hotkey: default_hotkey("Meta+Shift+P", "Ctrl+Shift+P"), - }, - ), - ( - ActionInvocation::Global { action: GlobalAction::ProxyStop }, - ActionMetadata { - label: "Stop Proxy".into(), - default_hotkey: default_hotkey("Meta+Shift+S", "Ctrl+Shift+S"), + label: "Pause or Resume Recording".into(), + // Not Cmd+Shift+R, which reloads the window in development builds. + default_hotkey: default_hotkey("Meta+R", "Ctrl+Shift+R"), }, ), ( diff --git a/crates-proxy/yaak-proxy-lib/src/attribution.rs b/crates-proxy/yaak-proxy-lib/src/attribution.rs new file mode 100644 index 000000000..8e8ae4b3d --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/src/attribution.rs @@ -0,0 +1,1051 @@ +//! Which app opened each client connection. The client's end of a connection to the engine is a +//! loopback TCP socket whose local port is the client's ephemeral port and whose foreign port is +//! the engine's, so the app behind it is the process holding that socket. On macOS one worker +//! thread finds it through `libproc`, once per connection, checking the processes that opened +//! recent connections before scanning them all. Other platforms report nothing. + +use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; +use std::time::Duration; +use ts_rs::TS; + +/// The app behind a client connection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct ClientApp { + pub pid: i32, + /// The app's name ("Google Chrome"), or the executable's for a process outside an app ("curl"). + pub name: String, + /// The outermost `.app` bundle's CFBundleIdentifier, if any. + pub bundle_id: Option, + /// The outermost `.app` bundle's path, or the executable's path when there is no bundle. + pub path: String, +} + +/// Finds the app behind each client connection, once per connection, off the caller's thread. +/// Cheap to clone. +#[derive(Clone)] +pub struct Attribution { + lookups: Option, +} + +impl Attribution { + /// For an engine listening on `proxy_port`. On platforms without a lookup this does nothing + /// and spawns nothing. + pub fn new(proxy_port: u16) -> Self { + Self { lookups: imp::Lookups::start(proxy_port) } + } + + /// Starts finding the app behind `connection`, whose client end is `client_addr`. Called on + /// each request; only the first call per connection does anything. Returns immediately. + pub fn begin(&self, connection: u64, client_addr: SocketAddr) { + if let Some(lookups) = &self.lookups { + lookups.begin(connection, client_addr); + } + } + + /// The app behind `connection`, waiting up to `wait` for a lookup still in progress. None + /// when unknown, not found, or not supported. + pub fn get(&self, connection: u64, wait: Duration) -> Option { + self.lookups.as_ref().and_then(|l| l.get(connection, wait)) + } +} + +/// The icon of the app or executable at `path` (a ClientApp's `path`) as a PNG, 64 by 64 +/// pixels, cached in memory by path. `Ok(None)` on platforms without icons. Blocks. +#[cfg(target_os = "macos")] +pub fn app_icon_png(path: &str) -> Result>, String> { + icon::png(path).map(Some) +} + +#[cfg(not(target_os = "macos"))] +pub fn app_icon_png(_path: &str) -> Result>, String> { + Ok(None) +} + +/// A map that keeps only its `cap` most recently inserted keys. +#[cfg(target_os = "macos")] +struct Bounded { + map: std::collections::HashMap, + order: std::collections::VecDeque, + cap: usize, +} + +#[cfg(target_os = "macos")] +impl Bounded { + fn new(cap: usize) -> Self { + Self { map: Default::default(), order: Default::default(), cap } + } + + fn get(&self, key: &Q) -> Option<&V> + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + self.map.get(key) + } + + fn get_mut(&mut self, key: &K) -> Option<&mut V> { + self.map.get_mut(key) + } + + fn insert(&mut self, key: K, value: V) { + if self.map.insert(key.clone(), value).is_none() { + self.order.push_back(key); + } + while self.order.len() > self.cap { + if let Some(oldest) = self.order.pop_front() { + self.map.remove(&oldest); + } + } + } +} + +#[cfg(not(target_os = "macos"))] +mod imp { + use super::ClientApp; + use std::net::SocketAddr; + use std::time::Duration; + + /// No lookup on this platform. + #[derive(Clone)] + pub enum Lookups {} + + impl Lookups { + pub fn start(_proxy_port: u16) -> Option { + None + } + + pub fn begin(&self, _connection: u64, _client_addr: SocketAddr) { + match *self {} + } + + pub fn get(&self, _connection: u64, _wait: Duration) -> Option { + match *self {} + } + } +} + +#[cfg(target_os = "macos")] +mod imp { + use super::{Bounded, ClientApp, proc}; + use log::warn; + use std::collections::{HashMap, HashSet}; + use std::net::SocketAddr; + use std::path::Path; + use std::sync::mpsc::{Receiver, Sender, channel}; + use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError, Weak}; + use std::time::{Duration, Instant}; + + /// Connections whose lookups are remembered, the most recently begun. + const CONNECTIONS: usize = 4096; + /// Pids that recently owned client sockets, checked before a full scan. + const RECENT_PIDS: usize = 256; + /// Apps remembered by pid and start time. + const APPS: usize = 512; + + /// The lookup state shared by every clone of an `Attribution`, and its worker's sender. + /// The worker stops once the last sender is dropped. + #[derive(Clone)] + pub struct Lookups { + shared: Arc, + jobs: Sender, + } + + struct Shared { + connections: Mutex>, + done: Condvar, + } + + enum Slot { + Pending, + Done(Option), + } + + struct Job { + connection: u64, + client_port: u16, + } + + impl Shared { + fn lock(&self) -> MutexGuard<'_, Bounded> { + self.connections.lock().unwrap_or_else(PoisonError::into_inner) + } + } + + impl Lookups { + /// Spawns the worker, or returns None (and logs) when the thread cannot start. + pub fn start(proxy_port: u16) -> Option { + let shared = Arc::new(Shared { + connections: Mutex::new(Bounded::new(CONNECTIONS)), + done: Condvar::new(), + }); + let (jobs, rx) = channel(); + let weak = Arc::downgrade(&shared); + let spawned = std::thread::Builder::new() + .name("yaak-proxy-attribution".into()) + .spawn(move || work(weak, rx, proxy_port)); + match spawned { + Ok(_) => Some(Self { shared, jobs }), + Err(e) => { + warn!("Failed to start the client app lookup thread: {e}"); + None + } + } + } + + pub fn begin(&self, connection: u64, client_addr: SocketAddr) { + let mut connections = self.shared.lock(); + if connections.get(&connection).is_some() { + return; + } + // A client on another host has no process here. + if !is_loopback(client_addr) { + connections.insert(connection, Slot::Done(None)); + return; + } + connections.insert(connection, Slot::Pending); + let job = Job { connection, client_port: client_addr.port() }; + // The worker is gone only if it panicked; nobody should wait on it then. + if self.jobs.send(job).is_err() { + connections.insert(connection, Slot::Done(None)); + } + } + + pub fn get(&self, connection: u64, wait: Duration) -> Option { + let deadline = Instant::now() + wait; + let mut connections = self.shared.lock(); + loop { + match connections.get(&connection)? { + Slot::Done(app) => return app.clone(), + Slot::Pending => {} + } + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return None; + } + connections = match self.shared.done.wait_timeout(connections, left) { + Ok((guard, _)) => guard, + Err(e) => e.into_inner().0, + }; + } + } + } + + fn is_loopback(addr: SocketAddr) -> bool { + match addr { + SocketAddr::V4(a) => a.ip().is_loopback(), + SocketAddr::V6(a) => { + a.ip().is_loopback() || a.ip().to_ipv4_mapped().is_some_and(|v4| v4.is_loopback()) + } + } + } + + /// Resolves every job queued by the time it wakes in one pass, so a burst of short-lived + /// clients costs one scan rather than one each, and their sockets are still open for it. + fn work(shared: Weak, jobs: Receiver, proxy_port: u16) { + let mut finder = Finder::new(proxy_port); + while let Ok(first) = jobs.recv() { + let mut batch = vec![first]; + while let Ok(job) = jobs.try_recv() { + batch.push(job); + } + let Some(shared) = shared.upgrade() else { + return; + }; + // Some may be forgotten already, under a burst of newer connections. + batch.retain(|job| shared.lock().get(&job.connection).is_some()); + if batch.is_empty() { + continue; + } + let ports: Vec = batch.iter().map(|job| job.client_port).collect(); + let apps = finder.find_all(&ports); + let mut connections = shared.lock(); + for job in batch { + if let Some(slot) = connections.get_mut(&job.connection) { + *slot = Slot::Done(apps.get(&job.client_port).cloned().flatten()); + } + } + drop(connections); + shared.done.notify_all(); + } + } + + /// Finds the processes holding client sockets and the apps they belong to, with the caches + /// and buffers kept between lookups. + pub(super) struct Finder { + proxy_port: u16, + own_pid: i32, + /// Most recent first. + recent: Vec, + apps: Bounded<(i32, u64, u64), Option>, + pids: Vec, + fds: Vec, + held: Vec, + } + + impl Finder { + pub fn new(proxy_port: u16) -> Self { + Self { + proxy_port, + own_pid: std::process::id() as i32, + recent: Vec::new(), + apps: Bounded::new(APPS), + pids: Vec::new(), + fds: Vec::new(), + held: Vec::new(), + } + } + + /// The app behind each of the connections from `client_ports`, None for those not found. + pub fn find_all(&mut self, client_ports: &[u16]) -> HashMap> { + self.find_pids(client_ports) + .into_iter() + .map(|(port, pid)| (port, self.app(pid))) + .collect() + } + + /// The pids holding the client ends of connections from `client_ports`: first among the + /// pids that held recent ones, then in one scan of every other pid, stopping once all + /// are found. + pub fn find_pids(&mut self, client_ports: &[u16]) -> HashMap { + let mut pending: HashSet = client_ports.iter().copied().collect(); + let mut found = HashMap::with_capacity(pending.len()); + let mut hits = Vec::new(); + + let mut i = 0; + while i < self.recent.len() && !pending.is_empty() { + let pid = self.recent[i]; + let before = pending.len(); + if !self.check(pid, &mut pending, &mut found) { + self.recent.remove(i); + continue; + } + if pending.len() < before { + hits.push(pid); + } + i += 1; + } + + if !pending.is_empty() { + let recent: HashSet = self.recent.iter().copied().collect(); + let mut pids = std::mem::take(&mut self.pids); + proc::all_pids(&mut pids); + for &pid in &pids { + if pending.is_empty() { + break; + } + if pid <= 0 || pid == self.own_pid || recent.contains(&pid) { + continue; + } + let before = pending.len(); + self.check(pid, &mut pending, &mut found); + if pending.len() < before { + hits.push(pid); + } + } + self.pids = pids; + } + + self.recent.retain(|pid| !hits.contains(pid)); + self.recent.splice(0..0, hits); + self.recent.truncate(RECENT_PIDS); + found + } + + /// Moves the ports in `pending` that `pid` holds into `found`. False when `pid` cannot + /// be read. + fn check( + &mut self, + pid: i32, + pending: &mut HashSet, + found: &mut HashMap, + ) -> bool { + let readable = + proc::held_ports(pid, self.proxy_port, pending, &mut self.fds, &mut self.held); + for port in self.held.drain(..) { + if pending.remove(&port) { + found.insert(port, pid); + } + } + readable + } + + fn app(&mut self, pid: i32) -> Option { + let key = proc::start_time(pid).map(|(sec, usec)| (pid, sec, usec)); + if let Some(key) = key + && let Some(app) = self.apps.get(&key) + { + return app.clone(); + } + let app = proc::executable(pid).map(|exe| describe(pid, &exe)); + if let Some(key) = key { + self.apps.insert(key, app.clone()); + } + app + } + } + + /// The app that `exe`, the executable of `pid`, belongs to. + pub(super) fn describe(pid: i32, exe: &Path) -> ClientApp { + match outermost_bundle(exe) { + Some(bundle) => { + let (name, bundle_id) = bundle_info(bundle); + ClientApp { pid, name, bundle_id, path: bundle.to_string_lossy().into_owned() } + } + None => ClientApp { + pid, + name: exe + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| exe.to_string_lossy().into_owned()), + bundle_id: None, + path: exe.to_string_lossy().into_owned(), + }, + } + } + + /// The outermost ancestor directory of `exe` whose name ends in `.app`, so a helper app + /// nested inside another app resolves to the app the user launched. + pub(super) fn outermost_bundle(exe: &Path) -> Option<&Path> { + exe.ancestors() + .skip(1) + .filter(|dir| dir.file_name().is_some_and(|n| n.to_string_lossy().ends_with(".app"))) + .last() + } + + /// The name and bundle identifier of the app bundle at `bundle`, from its `Info.plist`: + /// `CFBundleDisplayName`, then `CFBundleName`, then the bundle's file stem. + pub(super) fn bundle_info(bundle: &Path) -> (String, Option) { + let info = plist::Value::from_file(bundle.join("Contents").join("Info.plist")) + .ok() + .and_then(plist::Value::into_dictionary); + let string = |key: &str| { + info.as_ref() + .and_then(|d| d.get(key)) + .and_then(plist::Value::as_string) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + }; + let name = + string("CFBundleDisplayName").or_else(|| string("CFBundleName")).unwrap_or_else(|| { + bundle.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default() + }); + (name, string("CFBundleIdentifier")) + } +} + +/// The `libproc` calls and the `` structs `libc` does not have, named as the +/// header names them, with every field so the layouts match. +#[cfg(target_os = "macos")] +#[allow(non_camel_case_types, dead_code)] +mod proc { + use libc::{c_char, c_int, c_void}; + use std::collections::HashSet; + use std::ffi::OsStr; + use std::mem::size_of; + use std::net::{Ipv4Addr, Ipv6Addr}; + use std::os::unix::ffi::OsStrExt; + use std::path::PathBuf; + + pub const PROC_PIDFDSOCKETINFO: c_int = 3; + pub const SOCKINFO_TCP: c_int = 2; + pub const INI_IPV4: u8 = 0x1; + + #[repr(C)] + #[derive(Clone, Copy)] + pub struct proc_fileinfo { + pub fi_openflags: u32, + pub fi_status: u32, + pub fi_offset: i64, + pub fi_type: i32, + pub fi_guardflags: u32, + } + + #[repr(C)] + #[derive(Clone, Copy)] + pub struct sockbuf_info { + pub sbi_cc: u32, + pub sbi_hiwat: u32, + pub sbi_mbcnt: u32, + pub sbi_mbmax: u32, + pub sbi_lowat: u32, + pub sbi_flags: i16, + pub sbi_timeo: i16, + } + + /// The `in4in6_addr` / `in6_addr` union: an IPv4 address is the last four bytes. + #[repr(C, align(4))] + #[derive(Clone, Copy)] + pub struct in_addr_46 { + pub bytes: [u8; 16], + } + + #[repr(C)] + #[derive(Clone, Copy)] + pub struct insi_v6 { + pub in6_hlim: u8, + pub in6_cksum: c_int, + pub in6_ifindex: u16, + pub in6_hops: i16, + } + + #[repr(C)] + #[derive(Clone, Copy)] + pub struct in_sockinfo { + /// The foreign port, network byte order in the low 16 bits. + pub insi_fport: c_int, + /// The local port, network byte order in the low 16 bits. + pub insi_lport: c_int, + pub insi_gencnt: u64, + pub insi_flags: u32, + pub insi_flow: u32, + pub insi_vflag: u8, + pub insi_ip_ttl: u8, + pub rfu_1: u32, + pub insi_faddr: in_addr_46, + pub insi_laddr: in_addr_46, + pub insi_v4: u8, + pub insi_v6: insi_v6, + } + + #[repr(C)] + #[derive(Clone, Copy)] + pub struct tcp_sockinfo { + pub tcpsi_ini: in_sockinfo, + pub tcpsi_state: c_int, + pub tcpsi_timer: [c_int; 4], + pub tcpsi_mss: c_int, + pub tcpsi_flags: u32, + pub rfu_1: u32, + pub tcpsi_tp: u64, + } + + /// Only here so `soi_proto` has the size of its largest member. + #[repr(C)] + #[derive(Clone, Copy)] + pub struct un_sockinfo { + pub unsi_conn_so: u64, + pub unsi_conn_pcb: u64, + pub unsi_addr: [c_char; 255], + pub unsi_caddr: [c_char; 255], + } + + #[repr(C)] + #[derive(Clone, Copy)] + pub union soi_proto { + pub pri_in: in_sockinfo, + pub pri_tcp: tcp_sockinfo, + pub pri_un: un_sockinfo, + } + + #[repr(C)] + #[derive(Clone, Copy)] + pub struct socket_info { + pub soi_stat: libc::vinfo_stat, + pub soi_so: u64, + pub soi_pcb: u64, + pub soi_type: c_int, + pub soi_protocol: c_int, + pub soi_family: c_int, + pub soi_options: i16, + pub soi_linger: i16, + pub soi_state: i16, + pub soi_qlen: i16, + pub soi_incqlen: i16, + pub soi_qlimit: i16, + pub soi_timeo: i16, + pub soi_error: u16, + pub soi_oobmark: u32, + pub soi_rcv: sockbuf_info, + pub soi_snd: sockbuf_info, + pub soi_kind: c_int, + pub rfu_1: u32, + pub soi_proto: soi_proto, + } + + #[repr(C)] + #[derive(Clone, Copy)] + pub struct socket_fdinfo { + pub pfi: proc_fileinfo, + pub psi: socket_info, + } + + /// Fills `pids` with every pid on the system. + pub fn all_pids(pids: &mut Vec) { + pids.clear(); + // With no buffer this returns about how many pids there are. + let count = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) }; + if count <= 0 { + return; + } + pids.resize(count as usize + 64, 0); + let bytes = (pids.len() * size_of::()) as c_int; + let count = unsafe { libc::proc_listallpids(pids.as_mut_ptr().cast::(), bytes) }; + pids.truncate(count.max(0) as usize); + } + + /// Fills `fds` with the open fds of `pid`. False when the process is gone or not ours to read. + fn list_fds(pid: i32, fds: &mut Vec) -> bool { + fds.clear(); + let entry = size_of::(); + // With no buffer this returns the bytes needed for the fd table. + let bytes = + unsafe { libc::proc_pidinfo(pid, libc::PROC_PIDLISTFDS, 0, std::ptr::null_mut(), 0) }; + if bytes <= 0 { + return false; + } + fds.resize(bytes as usize / entry + 16, libc::proc_fdinfo { proc_fd: 0, proc_fdtype: 0 }); + let bytes = unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDLISTFDS, + 0, + fds.as_mut_ptr().cast::(), + (fds.len() * entry) as c_int, + ) + }; + if bytes <= 0 { + fds.clear(); + return false; + } + fds.truncate(bytes as usize / entry); + true + } + + /// Fills `held` with the ports of `wanted` that `pid` holds the client end of: a loopback + /// TCP socket from that port to `proxy_port`. False when its fds cannot be read. + pub fn held_ports( + pid: i32, + proxy_port: u16, + wanted: &HashSet, + fds: &mut Vec, + held: &mut Vec, + ) -> bool { + held.clear(); + if !list_fds(pid, fds) { + return false; + } + let size = size_of::(); + for fd in fds.iter().filter(|fd| fd.proc_fdtype == libc::PROX_FDTYPE_SOCKET as u32) { + let mut info = std::mem::MaybeUninit::::zeroed(); + let written = unsafe { + libc::proc_pidfdinfo( + pid, + fd.proc_fd, + PROC_PIDFDSOCKETINFO, + info.as_mut_ptr().cast::(), + size as c_int, + ) + }; + if written as usize != size { + continue; + } + // Zeroed and then filled by the kernel, so every field holds a valid value. + let info = unsafe { info.assume_init() }; + if info.psi.soi_kind != SOCKINFO_TCP { + continue; + } + // soi_kind says the union holds a tcp_sockinfo. + let ini = unsafe { info.psi.soi_proto.pri_tcp.tcpsi_ini }; + let local = port(ini.insi_lport); + if port(ini.insi_fport) == proxy_port + && wanted.contains(&local) + && is_loopback(ini.insi_vflag, &ini.insi_laddr) + && is_loopback(ini.insi_vflag, &ini.insi_faddr) + { + held.push(local); + if held.len() == wanted.len() { + break; + } + } + } + true + } + + pub fn port(raw: c_int) -> u16 { + u16::from_be(raw as u16) + } + + pub fn is_loopback(vflag: u8, addr: &in_addr_46) -> bool { + let b = addr.bytes; + if vflag & INI_IPV4 != 0 { + return Ipv4Addr::new(b[12], b[13], b[14], b[15]).is_loopback(); + } + let v6 = Ipv6Addr::from(b); + v6.is_loopback() || v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback()) + } + + /// When `pid` started, in seconds and microseconds, which tells a reused pid apart. + pub fn start_time(pid: i32) -> Option<(u64, u64)> { + let mut info = std::mem::MaybeUninit::::zeroed(); + let size = size_of::(); + let written = unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr().cast::(), + size as c_int, + ) + }; + if written as usize != size { + return None; + } + let info = unsafe { info.assume_init() }; + Some((info.pbi_start_tvsec, info.pbi_start_tvusec)) + } + + /// The path of the executable `pid` runs. + pub fn executable(pid: i32) -> Option { + let mut buf = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; + let len = + unsafe { libc::proc_pidpath(pid, buf.as_mut_ptr().cast::(), buf.len() as u32) }; + if len <= 0 { + return None; + } + buf.truncate(len as usize); + Some(PathBuf::from(OsStr::from_bytes(&buf))) + } +} + +/// App icons drawn with AppKit. +#[cfg(target_os = "macos")] +mod icon { + use super::Bounded; + use objc2::AllocAnyThread; + use objc2::rc::autoreleasepool; + use objc2_app_kit::{ + NSBitmapImageFileType, NSBitmapImageRep, NSCompositingOperation, NSDeviceRGBColorSpace, + NSGraphicsContext, NSWorkspace, + }; + use objc2_foundation::{NSDictionary, NSPoint, NSRect, NSSize, NSString}; + use std::path::Path; + use std::sync::{LazyLock, Mutex, PoisonError}; + + const SIZE: isize = 64; + const CACHED: usize = 256; + + static ICONS: LazyLock>>> = + LazyLock::new(|| Mutex::new(Bounded::new(CACHED))); + + pub fn png(path: &str) -> Result, String> { + if let Some(png) = ICONS.lock().unwrap_or_else(PoisonError::into_inner).get(path) { + return Ok(png.clone()); + } + if !Path::new(path).exists() { + return Err(format!("No app at {path}")); + } + let png = autoreleasepool(|_| draw(path))?; + ICONS.lock().unwrap_or_else(PoisonError::into_inner).insert(path.to_string(), png.clone()); + Ok(png) + } + + fn draw(path: &str) -> Result, String> { + let image = NSWorkspace::sharedWorkspace().iconForFile(&NSString::from_str(path)); + // A null planes pointer has the rep allocate its own pixels. + let rep = unsafe { + NSBitmapImageRep::initWithBitmapDataPlanes_pixelsWide_pixelsHigh_bitsPerSample_samplesPerPixel_hasAlpha_isPlanar_colorSpaceName_bytesPerRow_bitsPerPixel( + NSBitmapImageRep::alloc(), + std::ptr::null_mut(), + SIZE, + SIZE, + 8, + 4, + true, + false, + NSDeviceRGBColorSpace, + 0, + 0, + ) + } + .ok_or("Failed to create a bitmap for the icon")?; + let size = NSSize::new(SIZE as f64, SIZE as f64); + rep.setSize(size); + let context = NSGraphicsContext::graphicsContextWithBitmapImageRep(&rep) + .ok_or("Failed to create a graphics context for the icon")?; + NSGraphicsContext::saveGraphicsState_class(); + NSGraphicsContext::setCurrentContext(Some(&context)); + image.drawInRect_fromRect_operation_fraction( + NSRect::new(NSPoint::ZERO, size), + NSRect::ZERO, + NSCompositingOperation::Copy, + 1.0, + ); + context.flushGraphics(); + NSGraphicsContext::restoreGraphicsState_class(); + // An empty properties dictionary is the right type for any key. + let data = unsafe { + rep.representationUsingType_properties(NSBitmapImageFileType::PNG, &NSDictionary::new()) + } + .ok_or("Failed to encode the icon as PNG")?; + Ok(data.to_vec()) + } +} + +#[cfg(all(test, target_os = "macos"))] +mod tests { + use super::imp::{Finder, bundle_info, describe, outermost_bundle}; + use super::proc::*; + use super::*; + use std::mem::{offset_of, size_of}; + use std::net::{Ipv4Addr, Ipv6Addr, TcpListener}; + use std::path::Path; + use std::process::{Child, Command, Stdio}; + use std::time::Instant; + + #[test] + fn struct_layouts_match_the_sdk() { + // sizeof and offsetof from in the macOS SDK. + assert_eq!(size_of::(), 24); + assert_eq!(size_of::(), 136); + assert_eq!(size_of::(), 24); + assert_eq!(size_of::(), 80); + assert_eq!(size_of::(), 120); + assert_eq!(size_of::(), 528); + assert_eq!(size_of::(), 768); + assert_eq!(size_of::(), 792); + assert_eq!(offset_of!(in_sockinfo, insi_lport), 4); + assert_eq!(offset_of!(in_sockinfo, insi_vflag), 24); + assert_eq!(offset_of!(in_sockinfo, insi_faddr), 32); + assert_eq!(offset_of!(in_sockinfo, insi_laddr), 48); + assert_eq!(offset_of!(in_sockinfo, insi_v6), 68); + assert_eq!(offset_of!(socket_info, soi_kind), 232); + assert_eq!(offset_of!(socket_info, soi_proto), 240); + assert_eq!(offset_of!(socket_fdinfo, psi), 24); + } + + #[test] + fn reads_ports_and_loopback() { + assert_eq!(port(i32::from(9090u16.to_be())), 9090); + let mut v4 = in_addr_46 { bytes: [0; 16] }; + v4.bytes[12..].copy_from_slice(&[127, 0, 0, 1]); + assert!(is_loopback(INI_IPV4, &v4)); + let v6 = in_addr_46 { bytes: Ipv6Addr::LOCALHOST.octets() }; + assert!(is_loopback(2, &v6)); + let mapped = in_addr_46 { bytes: Ipv4Addr::new(127, 0, 0, 1).to_ipv6_mapped().octets() }; + assert!(is_loopback(2, &mapped)); + let remote = in_addr_46 { bytes: Ipv4Addr::new(10, 0, 0, 1).to_ipv6_mapped().octets() }; + assert!(!is_loopback(2, &remote)); + } + + fn fake_app(dir: &Path, rel: &str, plist: Option<&str>) { + let bundle = dir.join(rel); + std::fs::create_dir_all(bundle.join("Contents/MacOS")).unwrap(); + if let Some(plist) = plist { + std::fs::write(bundle.join("Contents/Info.plist"), plist).unwrap(); + } + } + + fn plist_xml(entries: &[(&str, &str)]) -> String { + let body: String = + entries.iter().map(|(k, v)| format!("{k}{v}")).collect(); + format!( + r#"{body}"# + ) + } + + #[test] + fn resolves_the_outermost_bundle() { + let dir = tempfile::tempdir().unwrap(); + let outer = "Fake Browser.app"; + let helper = "Fake Browser.app/Contents/Frameworks/Fake.framework/Helpers/Fake Helper (Renderer).app"; + fake_app( + dir.path(), + outer, + Some(&plist_xml(&[ + ("CFBundleIdentifier", "com.example.browser"), + ("CFBundleName", "Browser"), + ("CFBundleDisplayName", "Fake Browser"), + ])), + ); + fake_app( + dir.path(), + helper, + Some(&plist_xml(&[("CFBundleIdentifier", "com.example.browser.helper")])), + ); + let exe = dir.path().join(helper).join("Contents/MacOS/Fake Helper (Renderer)"); + let bundle = dir.path().join(outer); + assert_eq!(outermost_bundle(&exe), Some(bundle.as_path())); + assert_eq!( + describe(42, &exe), + ClientApp { + pid: 42, + name: "Fake Browser".into(), + bundle_id: Some("com.example.browser".into()), + path: bundle.to_string_lossy().into_owned(), + } + ); + } + + #[test] + fn falls_back_to_the_executable() { + let exe = Path::new("/usr/bin/curl"); + assert_eq!(outermost_bundle(exe), None); + assert_eq!( + describe(7, exe), + ClientApp { + pid: 7, + name: "curl".into(), + bundle_id: None, + path: "/usr/bin/curl".into() + } + ); + } + + #[test] + fn reads_bundle_names() { + let dir = tempfile::tempdir().unwrap(); + fake_app(dir.path(), "Named.app", Some(&plist_xml(&[("CFBundleName", "Only Name")]))); + assert_eq!(bundle_info(&dir.path().join("Named.app")), ("Only Name".into(), None)); + + fake_app(dir.path(), "Bare.app", None); + assert_eq!(bundle_info(&dir.path().join("Bare.app")), ("Bare".into(), None)); + + // A binary plist, as most shipped apps have. + let mut dict = plist::Dictionary::new(); + dict.insert("CFBundleIdentifier".into(), "com.example.binary".into()); + dict.insert("CFBundleDisplayName".into(), "Binary App".into()); + fake_app(dir.path(), "Binary.app", None); + plist::Value::Dictionary(dict) + .to_file_binary(dir.path().join("Binary.app/Contents/Info.plist")) + .unwrap(); + assert_eq!( + bundle_info(&dir.path().join("Binary.app")), + ("Binary App".into(), Some("com.example.binary".into())) + ); + } + + /// `n` curls connected to one listener, with their accepted ends and client addresses. + struct Curls { + port: u16, + children: Vec, + streams: Vec, + peers: Vec, + } + + impl Curls { + fn start(n: usize) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let children = (0..n) + .map(|_| { + Command::new("/usr/bin/curl") + .args([ + "-s", + "--max-time", + "5", + &format!("http://127.0.0.1:{port}/"), + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap() + }) + .collect(); + let (streams, peers) = (0..n).map(|_| listener.accept().unwrap()).unzip(); + Self { port, children, streams, peers } + } + + fn pids(&self) -> Vec { + let mut pids: Vec = self.children.iter().map(|c| c.id() as i32).collect(); + pids.sort(); + pids + } + + fn client_ports(&self) -> Vec { + self.peers.iter().map(|p| p.port()).collect() + } + } + + impl Drop for Curls { + fn drop(&mut self) { + self.streams.clear(); + for child in &mut self.children { + let _ = child.kill(); + let _ = child.wait(); + } + } + } + + #[test] + fn finds_curl() { + let curls = Curls::start(1); + let attribution = Attribution::new(curls.port); + attribution.begin(1, curls.peers[0]); + attribution.begin(1, curls.peers[0]); + let app = attribution.get(1, Duration::from_secs(2)).expect("found the app"); + assert_eq!(app.name, "curl"); + assert_eq!(app.bundle_id, None); + assert_eq!(vec![app.pid], curls.pids()); + assert_eq!(attribution.get(2, Duration::from_millis(10)), None); + } + + #[test] + fn finds_many_clients_in_one_pass() { + let curls = Curls::start(6); + let mut finder = Finder::new(curls.port); + // Port 1 is never a client, so the pass also scans every pid. + let mut ports = curls.client_ports(); + ports.push(1); + let apps = finder.find_all(&ports); + assert_eq!(apps.len(), 6); + let mut pids: Vec = apps.values().flatten().map(|a| a.pid).collect(); + pids.sort(); + assert_eq!(pids, curls.pids()); + assert!(apps.values().flatten().all(|a| a.name == "curl")); + // Each port maps to the curl that holds it, the same through the recent pids. + assert_eq!(finder.find_pids(&curls.client_ports()), finder.find_pids(&ports)); + + let attribution = Attribution::new(curls.port); + for (i, peer) in curls.peers.iter().enumerate() { + attribution.begin(i as u64, *peer); + } + for (i, port) in curls.client_ports().iter().enumerate() { + let app = attribution.get(i as u64, Duration::from_secs(2)); + assert_eq!(app, apps[port]); + } + } + + #[test] + fn draws_app_icons() { + let png = app_icon_png("/System/Applications/Calculator.app").unwrap().unwrap(); + assert!(png.starts_with(b"\x89PNG\r\n\x1a\n")); + assert_eq!(&png[16..24], &[0, 0, 0, 64, 0, 0, 0, 64]); + let cached = app_icon_png("/System/Applications/Calculator.app").unwrap().unwrap(); + assert_eq!(png, cached); + assert!(app_icon_png("/usr/bin/curl").unwrap().unwrap().starts_with(b"\x89PNG")); + } + + /// Run with `--ignored --nocapture` to see the timings. + #[test] + #[ignore] + fn times_lookups() { + let mut pids = Vec::new(); + all_pids(&mut pids); + eprintln!("{} pids", pids.len()); + for n in [1, 8, 32] { + let curls = Curls::start(n); + let ports = curls.client_ports(); + let mut finder = Finder::new(curls.port); + let start = Instant::now(); + let found = finder.find_pids(&ports); + let full = start.elapsed(); + let start = Instant::now(); + let cached = finder.find_pids(&ports); + let hit = start.elapsed(); + let mut with_miss = ports.clone(); + with_miss.push(1); + let start = Instant::now(); + finder.find_pids(&with_miss); + let miss = start.elapsed(); + assert_eq!(found.len(), n); + assert_eq!(cached, found); + eprintln!( + "{n} ports: full scan {full:?}, cached pids {hit:?}, cached plus one never found (whole scan) {miss:?}" + ); + } + } +} diff --git a/crates-proxy/yaak-proxy-lib/src/client.rs b/crates-proxy/yaak-proxy-lib/src/client.rs index 2f714a6cc..914fa4c3a 100644 --- a/crates-proxy/yaak-proxy-lib/src/client.rs +++ b/crates-proxy/yaak-proxy-lib/src/client.rs @@ -5,7 +5,8 @@ use crate::bodies::{self, DecodedBody}; use crate::ca::CaStatus; use crate::control::{ - self, ControlInfo, ControlStatus, ErrorBody, StartRequest, SystemProxyRequest, TRUNCATED_HEADER, + self, ControlInfo, ControlStatus, ErrorBody, RecordingRequest, StartRequest, + SystemProxyRequest, TRUNCATED_HEADER, }; use crate::db::ProxyQueryManager; use crate::flows::{self, FlowDetail, FlowFilter}; @@ -127,6 +128,11 @@ impl ControlClient { self.post("/ca/trust", &serde_json::json!({})).await } + /// Resumes or pauses storing flows in the owner's engine. + pub async fn set_recording(&self, enabled: bool) -> Result { + self.post("/recording", &RecordingRequest { enabled }).await + } + pub async fn system_proxy(&self) -> Result { self.get("/system-proxy").await } diff --git a/crates-proxy/yaak-proxy-lib/src/control.rs b/crates-proxy/yaak-proxy-lib/src/control.rs index 2a75e6a55..fca098329 100644 --- a/crates-proxy/yaak-proxy-lib/src/control.rs +++ b/crates-proxy/yaak-proxy-lib/src/control.rs @@ -205,6 +205,8 @@ pub struct ControlStatus { pub proxy_port: Option, /// The port the engine starts on. pub configured_port: u16, + /// Flows are stored. Paused, traffic still passes through, unrecorded. + pub recording: bool, pub flow_count: usize, pub ca: CaStatus, /// `None` when it could not be read. @@ -222,6 +224,11 @@ pub struct SystemProxyRequest { pub enabled: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecordingRequest { + pub enabled: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClearResponse { /// How many flows were deleted. @@ -344,11 +351,14 @@ impl ControlServer { } pub(crate) fn already_owned(owner: &ControlInfo) -> String { - let who = match owner.owner { + format!("The proxy is already running in {} (pid {})", owner_name(owner.owner), owner.pid) +} + +pub(crate) fn owner_name(owner: Owner) -> &'static str { + match owner { Owner::App => "Yaak Proxy", Owner::Cli => "the yaak CLI", - }; - format!("The proxy is already running in {who} (pid {})", owner.pid) + } } impl Drop for ControlServer { @@ -472,6 +482,11 @@ async fn route(state: &ServerState, req: Request) -> Result { + let body: RecordingRequest = read_json(req).await?; + ctx.set_recording(body.enabled); + json(&status(ctx).await?) + } (&Method::GET, ["flows"]) => { let filter = FlowFilter::from_query(&query).map_err(bad_request)?; json(&flows::list(&ctx.db, &filter)?) @@ -509,6 +524,7 @@ async fn route(state: &ServerState, req: Request) -> Result { @@ -541,6 +557,7 @@ async fn status(ctx: &ProxyCtx) -> Result { state: if proxy_port.is_some() { ProxyState::Running } else { ProxyState::Stopped }, proxy_port, configured_port: ctx.settings()?.port, + recording: ctx.recording(), flow_count: flows::count(&ctx.db)?, ca, system_proxy, diff --git a/crates-proxy/yaak-proxy-lib/src/convert.rs b/crates-proxy/yaak-proxy-lib/src/convert.rs index 00c6f6622..1c58c232f 100644 --- a/crates-proxy/yaak-proxy-lib/src/convert.rs +++ b/crates-proxy/yaak-proxy-lib/src/convert.rs @@ -7,6 +7,7 @@ use crate::models::{Flow, find_header}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::collections::BTreeMap; +use ts_rs::TS; use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter}; // The client's body types (`apps/yaak-client/lib/model_util.ts`). @@ -28,16 +29,18 @@ const HOP_BY_HOP: &[&str] = &[ /// Yaak works these out itself when it sends the request. const AUTOMATIC: &[&str] = &["host", "content-length", "accept-encoding"]; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] pub struct DroppedHeader { pub name: String, pub value: String, pub reason: DropReason, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_rpc.ts")] pub enum DropReason { /// Only meant for the hop between the app and the proxy. HopByHop, @@ -72,8 +75,8 @@ pub fn flow_to_request(flow: &Flow, body: &DecodedBody) -> Conversion { )); } + // No name, as in Yaak, which then shows the URL. let request = HttpRequest { - name: default_name(&url), method: flow.method.clone(), url, url_parameters, @@ -228,16 +231,6 @@ fn graphql_body(flow: &Flow, text: &str) -> Option> { Some(fields) } -/// The path, or the host for a request to the root. -fn default_name(url: &str) -> String { - let rest = url.split_once("://").map_or(url, |(_, rest)| rest); - match rest.split_once('/') { - Some((_, path)) if !path.is_empty() => format!("/{path}"), - Some((host, _)) => host.to_string(), - None => rest.to_string(), - } -} - #[cfg(test)] mod tests { use super::*; @@ -287,7 +280,7 @@ mod tests { let r = &c.request; assert_eq!(r.method, "GET"); assert_eq!(r.url, "https://api.github.com/search/issues"); - assert_eq!(r.name, "/search/issues"); + assert_eq!(r.name, ""); let params: Vec<(&str, &str)> = r.url_parameters.iter().map(|p| (p.name.as_str(), p.value.as_str())).collect(); assert_eq!(params, vec![("q", "is:open label:bug"), ("per_page", "5")]); @@ -391,7 +384,7 @@ mod tests { { "enabled": true, "name": "note", "value": "hello world!" }, ]) ); - assert_eq!(c.request.name, "/login"); + assert_eq!(c.request.name, "", "unnamed, so Yaak shows its URL"); } #[test] @@ -412,7 +405,7 @@ mod tests { let c = flow_to_request(&f, &body("hello")); assert_eq!(c.request.body_type.as_deref(), Some("other")); assert_eq!(c.request.body["text"], "hello"); - assert_eq!(c.request.name, "x.test"); + assert_eq!(c.request.name, ""); } #[test] diff --git a/crates-proxy/yaak-proxy-lib/src/db.rs b/crates-proxy/yaak-proxy-lib/src/db.rs index 9bccc7e4e..2ae3b8423 100644 --- a/crates-proxy/yaak-proxy-lib/src/db.rs +++ b/crates-proxy/yaak-proxy-lib/src/db.rs @@ -1,6 +1,7 @@ use include_dir::{Dir, include_dir}; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; +use rusqlite::functions::FunctionFlags; use rusqlite::{OpenFlags, Transaction, TransactionBehavior}; use std::path::Path; use std::time::Duration; @@ -76,10 +77,23 @@ pub fn open_blobs_read_only(path: &Path) -> Result { 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::(0)?)), + ) +} + fn read_only_pool(path: &Path) -> Result { let manager = SqliteConnectionManager::file(path) .with_flags(OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX) - .with_init(|conn| conn.busy_timeout(Duration::from_millis(5000))); + .with_init(|conn| { + conn.busy_timeout(Duration::from_millis(5000))?; + add_functions(conn) + }); Pool::builder() .max_size(2) .connection_timeout(Duration::from_secs(10)) @@ -91,7 +105,8 @@ fn file_pool(path: &Path, max_size: u32) -> Result { let manager = SqliteConnectionManager::file(path).with_init(|conn| { conn.pragma_update(None, "journal_mode", "WAL")?; conn.pragma_update(None, "synchronous", "NORMAL")?; - conn.busy_timeout(Duration::from_millis(5000)) + conn.busy_timeout(Duration::from_millis(5000))?; + add_functions(conn) }); Pool::builder() .max_size(max_size) @@ -117,10 +132,14 @@ mod tests { let path = dir.path().join("proxy.db"); drop(ProxyQueryManager::new(&path).unwrap()); - // As a build from before the saved request column left it. + // As a build from before the saved request and client columns left it. let conn = Connection::open(&path).unwrap(); conn.execute_batch( "ALTER TABLE flows DROP COLUMN saved_request_id; + ALTER TABLE flows DROP COLUMN client_pid; + ALTER TABLE flows DROP COLUMN client_name; + ALTER TABLE flows DROP COLUMN client_bundle_id; + ALTER TABLE flows DROP COLUMN client_path; INSERT INTO flows (id, url, method) VALUES ('fl_old', 'https://example.com', 'GET');", ) .unwrap(); @@ -130,6 +149,7 @@ mod tests { let flows = db.with_conn(|c| c.find_all::()).unwrap(); assert_eq!(flows.len(), 1); assert_eq!((flows[0].id.as_str(), flows[0].saved_request_id.as_deref()), ("fl_old", None)); + assert_eq!((flows[0].client_pid, flows[0].client_name.as_deref()), (None, None)); } #[test] @@ -169,6 +189,22 @@ mod tests { let flow_columns = columns(&conn, "flows"); assert!(flow_columns.contains(&"response_truncated".to_string())); + for column in [ + "client_pid", + "client_name", + "client_bundle_id", + "client_path", + ] { + assert!(flow_columns.contains(&column.to_string()), "{column} in {flow_columns:?}"); + } + let indexes: Vec = conn + .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'flows' AND sql IS NOT NULL") + .unwrap() + .query_map([], |r| r.get(0)) + .unwrap() + .map(|i| i.unwrap()) + .collect(); + assert_eq!(indexes, vec!["idx_flows_started_at_id"]); assert!(!flow_columns.iter().any(|c| c.contains("body")), "{flow_columns:?}"); let journal_mode: String = conn.query_row("PRAGMA journal_mode", [], |r| r.get(0)).unwrap(); diff --git a/crates-proxy/yaak-proxy-lib/src/flows.rs b/crates-proxy/yaak-proxy-lib/src/flows.rs index 3db99fce5..0ae72f4c5 100644 --- a/crates-proxy/yaak-proxy-lib/src/flows.rs +++ b/crates-proxy/yaak-proxy-lib/src/flows.rs @@ -3,6 +3,7 @@ use crate::db::ProxyQueryManager; use crate::models::{Flow, FlowBody, FlowIden}; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; +use rusqlite::types::Value; use serde::{Deserialize, Serialize}; use ts_rs::TS; use yaak_database::UpsertModelInfo; @@ -13,9 +14,21 @@ use yaak_models::blob_manager::BlobManager; #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_rpc.ts")] pub struct FlowFilter { + /// Search terms, any case, each of which must match the URL (host, path or query), the + /// method, the status (`404`, `40`, `4xx`) or the content type: `stripe post 4xx`. + #[ts(optional)] + pub q: Option, /// Part of the host, any case: `stripe` matches `api.stripe.com`. #[ts(optional)] pub host: Option, + /// 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>, + /// 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>, /// The method, any case. #[ts(optional)] pub method: Option, @@ -36,14 +49,17 @@ pub struct FlowFilter { } impl FlowFilter { - /// The filter from a query string's `host`, `method`, `status`, `limit`, `after` and - /// `before`. + /// The filter from a query string's `q`, `host`, `under`, `app`, `method`, `status`, + /// `limit`, `after` and `before`. `under` and `app` may repeat. pub fn from_query(query: &str) -> Result { let mut filter = Self::default(); for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { let value = Some(value.into_owned()).filter(|v| !v.is_empty()); match key.as_ref() { + "q" => filter.q = value, "host" => filter.host = value, + "under" => filter.under.get_or_insert_default().extend(value), + "app" => filter.app.get_or_insert_default().extend(value), "method" => filter.method = value, "status" => filter.status = value, "after" => filter.after = value, @@ -62,8 +78,17 @@ impl FlowFilter { pub fn to_query(&self) -> String { let mut query = url::form_urlencoded::Serializer::new(String::new()); let limit = self.limit.map(|l| l.to_string()); + for (key, value) in [("q", &self.q), ("host", &self.host)] { + if let Some(value) = value { + query.append_pair(key, value); + } + } + for (key, values) in [("under", &self.under), ("app", &self.app)] { + for value in values.iter().flatten() { + query.append_pair(key, value); + } + } for (key, value) in [ - ("host", &self.host), ("method", &self.method), ("status", &self.status), ("limit", &limit), @@ -77,56 +102,175 @@ impl FlowFilter { query.finish() } - pub fn matches(&self, flow: &Flow) -> bool { - if let Some(host) = &self.host - && !host_of(&flow.url).to_ascii_lowercase().contains(&host.to_ascii_lowercase()) - { - return false; + /// Narrows more than `after`, `before` and `limit` do, so it covers fewer than all flows. + pub fn narrows(&self) -> bool { + let given = |v: &Option| v.as_deref().is_some_and(|v| !v.trim().is_empty()); + given(&self.q) + || given(&self.host) + || given(&self.method) + || given(&self.status) + || self.under.is_some() + || self.app.is_some() + } + + /// The SQL condition for every field but `after`, `before` and `limit`, and its parameters. + fn condition(&self) -> (String, Vec) { + let mut sql = String::from("1"); + let mut params: Vec = Vec::new(); + for term in self.q.iter().flat_map(|q| q.split_whitespace()) { + let term = term.to_lowercase(); + sql.push_str( + " AND (instr(lower(url), ?) > 0 OR lower(method) = ? \ + OR instr(lower(content_type), ?) > 0", + ); + params.extend([ + term.clone().into(), + term.clone().into(), + term.clone().into(), + ]); + if let Some(status) = status_condition(&term, &mut params) { + sql.push_str(" OR "); + sql.push_str(&status); + } + sql.push(')'); } - if let Some(method) = &self.method - && !flow.method.eq_ignore_ascii_case(method) - { - return false; + if let Some(host) = self.host.as_deref().filter(|h| !h.is_empty()) { + sql.push_str(&format!(" AND instr(lower({HOST_SQL}), ?) > 0")); + params.push(host.to_lowercase().into()); + } + if let Some(under) = &self.under { + // The node itself, or anything below it, by the sidebar's rules. + let mut any = Vec::new(); + for node in under { + let node = normalize_node(node); + any.push("(sidebar_path(url) = ? OR substr(sidebar_path(url), 1, ?) = ?)"); + let below = format!("{node}/"); + params.extend([ + node.into(), + (below.chars().count() as i64).into(), + below.into(), + ]); + } + let any = if any.is_empty() { "0".to_string() } else { any.join(" OR ") }; + sql.push_str(&format!(" AND ({any})")); + } + if let Some(apps) = &self.app { + let marks = vec!["?"; apps.len()].join(", "); + sql.push_str(&format!(" AND coalesce(client_bundle_id, client_path) IN ({marks})")); + params.extend(apps.iter().map(|a| a.clone().into())); + } + if let Some(method) = self.method.as_deref().filter(|m| !m.is_empty()) { + sql.push_str(" AND lower(method) = ?"); + params.push(method.to_lowercase().into()); } match self.status.as_deref().map(str::trim) { - None | Some("") => true, - Some(s) if s.eq_ignore_ascii_case("error") => flow.error.is_some(), - Some(s) => match (flow.status, s.to_ascii_lowercase().strip_suffix("xx")) { - (Some(status), Some(class)) => class.parse() == Ok(status / 100), - (Some(status), None) => s.parse() == Ok(status), - (None, _) => false, + None | Some("") => {} + Some(s) if s.eq_ignore_ascii_case("error") => sql.push_str(" AND error IS NOT NULL"), + // A whole status or a class, not the start of one as a search term may be. + Some(s) if s.len() == 3 => match status_condition(&s.to_ascii_lowercase(), &mut params) + { + Some(status) => { + sql.push_str(" AND "); + sql.push_str(&status); + } + None => sql.push_str(" AND 0"), }, + Some(_) => sql.push_str(" AND 0"), } + (sql, params) } } -/// `host[:port]` of a captured URL. -fn host_of(url: &str) -> &str { - let rest = url.split_once("://").map_or(url, |(_, rest)| rest); - rest.split(['/', '?', '#']).next().unwrap_or_default() +/// The host and port of `url`, in SQL: what follows `://` up to the first `/`, `?` or `#`. +const HOST_SQL: &str = "(SELECT CASE WHEN instr(r, '/') > 0 THEN substr(r, 1, instr(r, '/') - 1) \ + ELSE r END FROM (SELECT replace(replace(CASE WHEN instr(url, '://') > 0 \ + THEN substr(url, instr(url, '://') + 3) ELSE url END, '?', '/'), '#', '/') AS r))"; + +/// Where the app's sidebar places a URL: its host, lowercased and without its scheme's default +/// port, then its path's non-empty segments, joined by `/`, as in `api.stripe.com/v1/charges`. +/// A URL without a scheme is a host of its own. `sidebarPath` in the app's `lib/search.ts` +/// follows the same rules, and the sidebar's nodes are built from it. +pub fn sidebar_path(url: &str) -> String { + let Some((scheme, rest)) = url.split_once("://") else { + return url.to_string(); + }; + let end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let (authority, rest) = rest.split_at(end); + let mut host = authority.to_ascii_lowercase(); + let default_port = match scheme.to_ascii_lowercase().as_str() { + "http" | "ws" => Some(":80"), + "https" | "wss" => Some(":443"), + _ => None, + }; + if let Some(port) = default_port + && host.ends_with(port) + { + host.truncate(host.len() - port.len()); + } + let path = rest.split(['?', '#']).next().unwrap_or_default(); + let mut key = host; + for segment in path.split('/').filter(|s| !s.is_empty()) { + key.push('/'); + key.push_str(segment); + } + key } -/// The flows `filter` covers, newest first: by start, then by id. Reads only as far into the -/// table as the limit needs, so a page costs the same however many flows there are. +/// A sidebar node as `sidebar_path` writes it, its host lowercased. +fn normalize_node(node: &str) -> String { + match node.split_once('/') { + Some((host, path)) => format!("{}/{path}", host.to_ascii_lowercase()), + None => node.to_ascii_lowercase(), + } +} + +/// The SQL condition for a status term: `404` exactly, `40` as the start of one, or a class +/// such as `4xx`. None when the term is not one. +fn status_condition(term: &str, params: &mut Vec) -> Option { + if let Some(class) = term.strip_suffix("xx") + && let Ok(class @ 1..=9) = class.parse::() + { + 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::().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, String> { db.with_conn(|c| list_in(c.conn().resolve(), filter)) } /// [`list`] and the count of every flow, read from one snapshot of the database, so the count -/// takes in exactly the flows written before the page was read. -pub fn page(db: &ProxyQueryManager, filter: &FlowFilter) -> Result<(Vec, usize), String> { +/// takes in exactly the flows written before the page was read. With a filter that narrows, +/// also the count of the flows it covers, whatever the limit. +pub fn page( + db: &ProxyQueryManager, + filter: &FlowFilter, +) -> Result<(Vec, usize, Option), String> { db.with_conn(|c| { let tx = c.conn().resolve().unchecked_transaction().map_err(|e| e.to_string())?; let flows = list_in(&tx, filter)?; let total = count_in(&tx)?; + let matching = if filter.narrows() { Some(count_matching(&tx, filter)?) } else { None }; tx.commit().map_err(|e| e.to_string())?; - Ok((flows, total)) + Ok((flows, total, matching)) }) } fn list_in(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result, String> { - let mut sql = String::from("SELECT * FROM flows WHERE 1"); - let mut params: Vec = Vec::new(); + let (condition, mut params) = filter.condition(); + let mut sql = format!("SELECT * FROM flows WHERE {condition}"); if let Some(id) = &filter.after { // By rowid, which only grows as flows are written. let rowid: i64 = conn @@ -136,7 +280,7 @@ fn list_in(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result params.push(rowid.into()); } if let Some(id) = &filter.before { - let started_at: rusqlite::types::Value = conn + let started_at: Value = conn .query_row("SELECT started_at FROM flows WHERE id = ?", [id], |r| r.get(0)) .map_err(|_| format!("No flow {id}"))?; sql.push_str(" AND (started_at, id) < (?, ?)"); @@ -144,21 +288,26 @@ fn list_in(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result params.push(id.clone().into()); } sql.push_str(" ORDER BY started_at DESC, id DESC"); + if let Some(limit) = filter.limit { + sql.push_str(" LIMIT ?"); + params.push((limit.min(i64::MAX as usize) as i64).into()); + } let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; let rows = stmt .query_map(rusqlite::params_from_iter(params), Flow::from_row) .map_err(|e| e.to_string())?; - let mut flows = Vec::new(); - for flow in rows { - if flows.len() >= filter.limit.unwrap_or(usize::MAX) { - break; - } - let flow = flow.map_err(|e| e.to_string())?; - if filter.matches(&flow) { - flows.push(flow); - } - } - Ok(flows) + rows.collect::, _>>().map_err(|e| e.to_string()) +} + +fn count_matching(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result { + let (condition, params) = filter.condition(); + conn.query_row( + &format!("SELECT COUNT(*) FROM flows WHERE {condition}"), + rusqlite::params_from_iter(params), + |r| r.get::<_, i64>(0), + ) + .map(|n| n as usize) + .map_err(|e| e.to_string()) } /// Deletes every flow and their bodies, in a transaction in each database. The flows' deletion @@ -288,26 +437,218 @@ mod tests { } } + /// A database of `flows`, started a second apart in the order given, and a function that + /// lists the urls a filter covers, newest first. + fn db_of(flows: Vec) -> (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 { + list(db, &filter).unwrap().into_iter().map(|f| f.url).collect() + } + #[test] fn filters() { - let ok = flow("https://api.stripe.com:8443/v1/charges?x=1", "POST", Some(200), None); - let missing = flow("http://example.com/missing", "GET", Some(404), None); - let failed = flow("https://down.example.com/", "GET", None, Some("refused")); - - let by = |filter: FlowFilter| { - [&ok, &missing, &failed].iter().filter(|f| filter.matches(f)).count() - }; + let (_dir, db) = db_of(vec![ + flow("https://api.stripe.com:8443/v1/charges?x=1", "POST", Some(200), None), + flow("http://example.com/missing", "GET", Some(404), None), + flow("https://down.example.com/", "GET", None, Some("refused")), + ]); + let by = |filter: FlowFilter| urls(&db, filter).len(); assert_eq!(by(FlowFilter::default()), 3); assert_eq!(by(FlowFilter { host: Some("STRIPE".into()), ..Default::default() }), 1); assert_eq!(by(FlowFilter { host: Some("example.com".into()), ..Default::default() }), 2); assert_eq!(by(FlowFilter { host: Some("charges".into()), ..Default::default() }), 0); + assert_eq!(by(FlowFilter { host: Some("8443".into()), ..Default::default() }), 1); assert_eq!(by(FlowFilter { method: Some("get".into()), ..Default::default() }), 2); assert_eq!(by(FlowFilter { status: Some("404".into()), ..Default::default() }), 1); assert_eq!(by(FlowFilter { status: Some("2xx".into()), ..Default::default() }), 1); assert_eq!(by(FlowFilter { status: Some("5XX".into()), ..Default::default() }), 0); + assert_eq!(by(FlowFilter { status: Some("40".into()), ..Default::default() }), 0); assert_eq!(by(FlowFilter { status: Some("error".into()), ..Default::default() }), 1); } + #[test] + fn search_matches_url_method_status_and_content_type() { + let json = |url: &str, method: &str, status| Flow { + content_type: Some("application/json; charset=utf-8".into()), + ..flow(url, method, Some(status), None) + }; + let (_dir, db) = db_of(vec![ + json("https://api.stripe.com/v1/charges?customer=cus_9", "POST", 200), + json("https://api.github.com/repos/yaak", "GET", 404), + Flow { + content_type: Some("image/png".into()), + ..flow("https://images.ctfassets.net/logo.png", "GET", Some(200), None) + }, + flow("http://localhost:3000/health", "HEAD", Some(503), None), + ]); + let search = |q: &str| { + let filter = FlowFilter { q: Some(q.into()), ..Default::default() }; + let mut found = urls(&db, filter); + found.sort(); + found + }; + let stripe = "https://api.stripe.com/v1/charges?customer=cus_9"; + let github = "https://api.github.com/repos/yaak"; + let logo = "https://images.ctfassets.net/logo.png"; + let health = "http://localhost:3000/health"; + // The host, the path and the query, any case. + assert_eq!(search("STRIPE"), vec![stripe]); + assert_eq!(search("/repos/"), vec![github]); + assert_eq!(search("cus_9"), vec![stripe]); + // The method, whole. + assert_eq!(search("head"), vec![health]); + assert_eq!(search("ead"), Vec::<&str>::new()); + // A status, its start, or its class. + assert_eq!(search("404"), vec![github]); + assert_eq!(search("50"), vec![health]); + assert_eq!(search("2xx"), vec![stripe, logo]); + // The content type. + assert_eq!(search("png"), vec![logo]); + assert_eq!(search("json"), vec![github, stripe]); + // Every term must match, each anywhere. + assert_eq!(search("json get"), vec![github]); + assert_eq!(search(" json 2xx "), vec![stripe]); + assert_eq!(search("json 5xx"), Vec::<&str>::new()); + // `%` and `_` are plain characters, not patterns. + assert_eq!(search("%"), Vec::<&str>::new()); + assert_eq!(search("cus_"), vec![stripe]); + assert_eq!(search(""), vec![health, github, stripe, logo]); + } + + #[test] + fn under_matches_a_host_or_a_path_as_the_sidebar_does() { + let (_dir, db) = db_of(vec![ + flow("https://api.stripe.com/v1/charges", "GET", Some(200), None), + flow("https://api.stripe.com/v1/charges/ch_1?expand=x", "GET", Some(200), None), + flow("https://api.stripe.com/v1/chargesx", "GET", Some(200), None), + flow("https://api.stripe.com:8443/v1/charges", "GET", Some(200), None), + flow("http://api.stripe.com", "GET", Some(200), None), + flow("https://api.stripe.community/", "GET", Some(200), None), + flow("https://example.com/v1//users", "GET", Some(200), None), + flow("http://x.com:80/a", "GET", Some(200), None), + flow("https://x.com:443/a", "GET", Some(200), None), + ]); + let under = |nodes: &[&str]| { + let nodes = nodes.iter().map(|n| n.to_string()).collect(); + urls(&db, FlowFilter { under: Some(nodes), ..Default::default() }).len() + }; + // A host on another port is a node of its own, as in the sidebar. + assert_eq!(under(&["api.stripe.com"]), 4); + assert_eq!(under(&["api.stripe.com:8443"]), 1); + assert_eq!(under(&["api.stripe.com/v1/charges"]), 2); + // Empty segments and default ports are left out, as the sidebar leaves them. + assert_eq!(under(&["example.com/v1/users"]), 1); + assert_eq!(under(&["x.com/a"]), 2); + assert_eq!(under(&["api.stripe.com/v1/charges", "example.com"]), 3); + assert_eq!(under(&[]), 0); + // The sidebar lowercases hosts; paths keep their case. + assert_eq!(under(&["API.stripe.com/v1/charges"]), 2); + assert_eq!(under(&["api.stripe.com/V1/charges"]), 0); + } + + /// The cases `lib/search.test.ts` checks `sidebarPath` against, so the two agree. + #[test] + fn sidebar_path_rules() { + for (url, path) in [ + ("https://example.com/v1//users", "example.com/v1/users"), + ("http://x.com:80/a", "x.com/a"), + ("https://x.com:443/a?q=1#f", "x.com/a"), + ("http://x.com:443/a", "x.com:443/a"), + ("https://API.Stripe.com:8443/", "api.stripe.com:8443"), + ("https://api.stripe.com", "api.stripe.com"), + ("https://api.stripe.com?x=/y", "api.stripe.com"), + ("http://[::1]:80/a/B", "[::1]/a/B"), + ("/api", "/api"), + ] { + assert_eq!(sidebar_path(url), path, "{url}"); + } + } + + #[test] + fn app_matches_a_bundle_id_or_a_path() { + let from = |bundle_id: Option<&str>, path: &str| Flow { + client_bundle_id: bundle_id.map(String::from), + client_path: Some(path.into()), + ..flow("https://example.com/", "GET", Some(200), None) + }; + let (_dir, db) = db_of(vec![ + from(Some("com.google.Chrome"), "/Applications/Google Chrome.app"), + from(Some("com.google.Chrome"), "/Applications/Google Chrome.app"), + from(None, "/usr/bin/curl"), + flow("https://example.com/", "GET", Some(200), None), + ]); + let app = |apps: &[&str]| { + let apps = apps.iter().map(|a| a.to_string()).collect(); + urls(&db, FlowFilter { app: Some(apps), ..Default::default() }).len() + }; + assert_eq!(app(&["com.google.Chrome"]), 2); + assert_eq!(app(&["/usr/bin/curl"]), 1); + assert_eq!(app(&["/Applications/Google Chrome.app"]), 0); + assert_eq!(app(&["com.google.Chrome", "/usr/bin/curl"]), 3); + } + + #[test] + fn a_search_pages_back_and_counts_what_it_matches() { + // Every third flow is a POST, newest last. + let (_dir, db) = db_of( + (0..30) + .map(|i| { + let method = if i % 3 == 0 { "POST" } else { "GET" }; + flow(&format!("https://api.example.com/{i}"), method, Some(200), None) + }) + .collect(), + ); + let search = |before: Option| 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::>(); + 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 = db.with_conn(|c| { + let conn = c.conn().resolve(); + let (condition, params) = + FlowFilter { q: Some("stripe".into()), ..Default::default() }.condition(); + let sql = format!( + "EXPLAIN QUERY PLAN SELECT * FROM flows WHERE {condition} \ + ORDER BY started_at DESC, id DESC LIMIT 500" + ); + let mut stmt = conn.prepare(&sql).unwrap(); + stmt.query_map(rusqlite::params_from_iter(params), |r| r.get::<_, String>(3)) + .unwrap() + .map(|r| r.unwrap()) + .collect() + }); + assert!(plan.iter().any(|p| p.contains("idx_flows_started_at_id")), "{plan:?}"); + assert!(!plan.iter().any(|p| p.contains("TEMP B-TREE")), "{plan:?}"); + } + #[test] fn after_follows_flows_as_they_are_recorded() { use yaak_database::UpdateSource; @@ -412,12 +753,19 @@ mod tests { #[test] fn query_round_trip() { let filter = FlowFilter { + q: Some("post json".into()), host: Some("api.stripe.com".into()), + under: Some(vec!["api.stripe.com/v1".into(), "example.com".into()]), + app: Some(vec!["com.google.Chrome".into()]), status: Some("4xx".into()), limit: Some(20), ..Default::default() }; - assert_eq!(filter.to_query(), "host=api.stripe.com&status=4xx&limit=20"); + assert_eq!( + filter.to_query(), + "q=post+json&host=api.stripe.com&under=api.stripe.com%2Fv1&under=example.com\ + &app=com.google.Chrome&status=4xx&limit=20" + ); assert_eq!(FlowFilter::from_query(&filter.to_query()).unwrap(), filter); assert_eq!(FlowFilter::from_query("").unwrap(), FlowFilter::default()); assert!(FlowFilter::from_query("limit=lots").is_err()); diff --git a/crates-proxy/yaak-proxy-lib/src/lib.rs b/crates-proxy/yaak-proxy-lib/src/lib.rs index efe5e1038..d57cc3dac 100644 --- a/crates-proxy/yaak-proxy-lib/src/lib.rs +++ b/crates-proxy/yaak-proxy-lib/src/lib.rs @@ -1,4 +1,5 @@ pub mod actions; +pub mod attribution; pub mod bodies; pub mod ca; pub mod client; @@ -13,8 +14,11 @@ pub mod save; pub mod settings; use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction}; +use crate::attribution::Attribution; use crate::ca::CaStatus; -use crate::control::{ControlFile, ControlInfo, Owner}; +use crate::client::ControlClient; +use crate::control::{ControlFile, ControlInfo, ControlServer, Owner}; +use crate::convert::DroppedHeader; use crate::db::ProxyQueryManager; use crate::macos::{SystemProxyBackend, SystemProxyStatus}; use crate::models::{Flow, FlowBody, FlowIden, ModelPayload, ProxyHeader, find_header}; @@ -26,10 +30,12 @@ use chrono::{DateTime, Utc}; use log::{debug, warn}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::io::Write; use std::net::{SocketAddr, TcpStream}; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, SystemTime}; use ts_rs::TS; use yaak_database::{ModelChangeEvent, UpdateSource, UpsertModelInfo}; use yaak_models::blob_manager::{BlobManager, BodyChunkWriter}; @@ -46,10 +52,19 @@ pub struct ProxyCtx { owner: Owner, client_data_dir: PathBuf, control: Arc>>, + /// The control API this app serves while it owns the engine. Not the CLI daemon's, which + /// holds its own. + control_server: Arc>>, /// Held through each system proxy change, password dialog included, so they apply in turn. system_proxy_turn: Arc>, /// The port the engine last ran on, which the system proxy may still point at. last_port: Arc>>, + /// Flows are stored. While paused, traffic still passes through, untouched and unrecorded. + recording: Arc>, + /// This Mac's web traffic is meant to point at the running engine. + system_proxy_on: Arc, + /// Why the engine last failed to start, until it starts. + start_error: Arc>>, pub db: ProxyQueryManager, pub blobs: BlobManager, pub events: RpcEventEmitter, @@ -68,8 +83,12 @@ impl ProxyCtx { owner: Owner::App, client_data_dir: save::client_data_dir(), control: Arc::new(Mutex::new(None)), + control_server: Arc::new(Mutex::new(None)), system_proxy_turn: Arc::new(Mutex::new(())), last_port: Arc::new(Mutex::new(None)), + recording: Arc::new(Mutex::new(RecordingLog::default())), + system_proxy_on: Arc::new(AtomicBool::new(false)), + start_error: Arc::new(Mutex::new(None)), db, blobs, events, @@ -115,8 +134,26 @@ impl ProxyCtx { /// Starts the engine on `port`, or the one in `settings.json`. Does nothing if it is /// running. Points the system proxy at it when the settings ask for that. Refused while - /// another process owns the engine, so there are never two. + /// another process owns the engine, so there are never two. A failure is kept until the + /// next start, for the app to show. pub fn start_on(&self, port: Option) -> 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 { + self.start_error.lock().ok()?.clone() + } + + fn start_engine(&self, port: Option) -> Result<(), String> { if let Some(owner) = control::live_owner(&self.data_dir) && owner.pid != std::process::id() { @@ -132,13 +169,30 @@ impl ProxyCtx { .map_err(|e| format!("Failed to load proxy CA: {e}"))?; let mut config = ProxyConfig::new(port.unwrap_or(settings.port), Arc::new(ca)); config.capture_body_limit = settings.capture_body_limit; + // Filled once the engine has bound, since the lookup matches sockets by its port. + let attribution_slot: Arc> = Arc::new(OnceLock::new()); + let slot = attribution_slot.clone(); + config.on_accept = Some(Arc::new(move |connection, client_addr| { + // A client that is answered quickly may exit soon after, so it is found before + // anything is read from it: a few milliseconds once per connection at most. + if let Some(attribution) = slot.get() { + attribution.begin(connection, client_addr); + attribution.get(connection, ACCEPT_ATTRIBUTION_WAIT); + } + })); let mut proxy_handle = yaak_proxy::start_proxy(config)?; + let attribution = Attribution::new(proxy_handle.port); + let _ = attribution_slot.set(attribution.clone()); if let Some(event_rx) = proxy_handle.take_event_rx() { - let db = self.db.clone(); - let blobs = self.blobs.clone(); - let events = self.events.clone(); - std::thread::spawn(move || run_event_loop(event_rx, db, blobs, events)); + let sink = FlowSink { + db: self.db.clone(), + blobs: self.blobs.clone(), + events: self.events.clone(), + attribution, + recording: self.recording.clone(), + }; + std::thread::spawn(move || run_event_loop(event_rx, sink)); } let bound = proxy_handle.port; @@ -148,13 +202,18 @@ impl ProxyCtx { if let Ok(mut last_port) = self.last_port.lock() { *last_port = Some(bound); } - if settings.system_proxy { + self.system_proxy_on.store(settings.system_proxy_at_launch, Ordering::SeqCst); + if settings.system_proxy_at_launch { // The password dialog can sit open for a while; the engine is up regardless. let ctx = self.clone(); std::thread::spawn(move || { if let Err(e) = ctx.change_system_proxy(true, bound, true) { warn!("Failed to turn on the system proxy: {e}"); + ctx.system_proxy_on.store(false, Ordering::SeqCst); } + // So the app reads the system proxy again once the dialog is answered. + ctx.events + .emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Running }); }); } self.events.emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Running }); @@ -163,6 +222,7 @@ impl ProxyCtx { /// Stops the engine and turns off a system proxy that points at it. pub fn stop(&self) -> Result<(), String> { + self.system_proxy_on.store(false, Ordering::SeqCst); let port = self.take_handle()?; if let Some(port) = port { let ctx = self.clone(); @@ -218,6 +278,83 @@ impl ProxyCtx { self.control.lock().ok()?.as_ref().map(|c| c.info.clone()) } + /// Claims the engine for this app and serves the control API, unless it already does. + /// Fails while another live owner, the CLI daemon, has it. Resets a system proxy a crashed + /// owner left pointing at its port. + pub fn serve_control_api(&self) -> Result<(), String> { + let mut slot = self.control_server.lock().map_err(|_| "lock poisoned".to_string())?; + if slot.is_some() { + return Ok(()); + } + // Read first: a crashed owner's file names the port its system proxy may still point at. + let previous = control::read(&self.data_dir); + *slot = Some(ControlServer::start(self.clone())?); + drop(slot); + + let mut ports: Vec = 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 { + 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) { if let Ok(mut control) = self.control.lock() && let Some(control) = control.as_mut() @@ -240,23 +377,59 @@ impl ProxyCtx { macos::status(port, self.system_proxy_backend()) } - /// Turns the system proxy on or off and remembers the choice. Turned on while the engine - /// is stopped, it waits for the engine to start, so the Mac is never pointed at nothing. - /// Blocks, through the password dialog. + /// Points this Mac's web traffic at the running engine, or stops, at once, through the + /// password dialog. Not saved: the `systemProxyAtLaunch` setting decides whether it turns + /// on as the engine starts. Blocks. pub fn set_system_proxy(&self, enabled: bool) -> Result { - let mut settings = self.settings()?; - let previous = settings.system_proxy; - settings.system_proxy = enabled; - settings.save(&self.settings_path())?; - let port = self.port().unwrap_or(settings.port); + let Some(port) = self.port() else { + if enabled { + return Err("The proxy is not running, so there is nothing to route to".into()); + } + // Clears one left pointing at the configured port; asks for nothing otherwise. + self.change_system_proxy(false, self.settings()?.port, false)?; + return self.system_proxy_status(); + }; + let previous = self.system_proxy_on.swap(enabled, Ordering::SeqCst); if let Err(e) = self.change_system_proxy(enabled, port, enabled) { - settings.system_proxy = previous; - let _ = settings.save(&self.settings_path()); + self.system_proxy_on.store(previous, Ordering::SeqCst); return Err(e); } self.system_proxy_status() } + /// Whether the system proxy turns on as the engine starts, saved in `settings.json`. + pub fn set_system_proxy_at_launch(&self, enabled: bool) -> Result<(), String> { + let mut settings = self.settings()?; + settings.system_proxy_at_launch = enabled; + settings.save(&self.settings_path()) + } + + /// Intercepts again the hosts passed through after clients rejected the CA, as they no + /// longer will once it is trusted. + pub fn forget_passthrough(&self) { + if let Ok(handle) = self.handle.lock() + && let Some(handle) = handle.as_ref() + { + handle.forget_passthrough(); + } + } + + /// Flows are being stored. + pub fn recording(&self) -> bool { + self.recording.lock().map(|r| r.is_on()).unwrap_or(true) + } + + /// Resumes or pauses storing flows. Paused, traffic still passes through the engine, but no + /// new flow is stored; one already under way is finished. + pub fn set_recording(&self, recording: bool) { + let changed = self.recording.lock().map(|mut r| r.set(recording)).unwrap_or(false); + if changed { + let state = + if self.port().is_some() { ProxyState::Running } else { ProxyState::Stopped }; + self.events.emit("proxy_state_changed", &ProxyStatePayload { state }); + } + } + /// Points the system proxy at `port`, or turns it off there, once the changes before it /// are done. With `follow_engine`, the change is dropped if the engine has moved on by its /// turn: an enable once the engine is off `port` or the setting is off, a disable once the @@ -271,7 +444,8 @@ impl ProxyCtx { let _turn = self.system_proxy_turn.lock().map_err(|_| "lock poisoned".to_string())?; if follow_engine { let on_port = self.port() == Some(port); - let current = if enabled { on_port && self.settings()?.system_proxy } else { !on_port }; + let wanted = self.system_proxy_on.load(Ordering::SeqCst); + let current = if enabled { on_port && wanted } else { !on_port }; if !current { return Ok(()); } @@ -302,6 +476,11 @@ impl ProxyCtx { Ok(()) } + /// Yaak has run on this machine, so there is a database to save into. + pub fn client_exists(&self) -> bool { + ClientDb::exists(&self.client_data_dir) + } + /// Yaak's workspaces and their folders, to save a flow into. pub fn workspaces(&self) -> Result, String> { ClientDb::open(&self.client_data_dir)?.workspaces() @@ -354,6 +533,52 @@ fn is_listening(port: u16) -> bool { TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() } +// -- Recording -- + +/// Changes to recording kept, so a request whose events are read late is still judged by when +/// it started. +const RECORDING_CHANGES: usize = 64; + +/// Whether flows are stored, and when that changed. +struct RecordingLog { + /// Oldest first, starting from recording since the beginning. + changes: std::collections::VecDeque<(SystemTime, bool)>, +} + +impl Default for RecordingLog { + fn default() -> Self { + Self { changes: [(SystemTime::UNIX_EPOCH, true)].into() } + } +} + +impl RecordingLog { + fn is_on(&self) -> bool { + self.changes.back().is_none_or(|(_, on)| *on) + } + + /// Returns whether it changed. + fn set(&mut self, on: bool) -> bool { + if self.is_on() == on { + return false; + } + self.changes.push_back((SystemTime::now(), on)); + while self.changes.len() > RECORDING_CHANGES { + self.changes.pop_front(); + } + true + } + + /// Whether flows were stored at `time`. + fn was_on_at(&self, time: SystemTime) -> bool { + self.changes + .iter() + .rev() + .find(|(at, _)| *at <= time) + .or(self.changes.front()) + .is_none_or(|(_, on)| *on) + } +} + // -- Proxy state -- #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] @@ -391,6 +616,9 @@ pub struct ListFlowsResponse { /// Flows in the database, whatever the filter. #[ts(type = "number")] pub total: usize, + /// Flows the filter covers, whatever the limit, when it narrows to fewer than all of them. + #[ts(type = "number | null")] + pub matching: Option, } #[derive(Deserialize, TS)] @@ -412,6 +640,130 @@ pub struct GetProxyStateResponse { pub state: ProxyState, /// The port the engine is bound to, while it runs. pub port: Option, + /// 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, + /// 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, +} + +#[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, + /// The engine's port while it runs. + pub proxy_port: Option, +} + +#[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, +} + +#[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, + /// Headers Yaak sets itself or that only meant something to the proxy. + pub dropped_headers: Vec, + /// 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, + /// What does not carry over exactly, one sentence each. + pub warnings: Vec, + /// 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, + /// None or blank leaves the request unnamed, and Yaak shows its URL. + #[ts(optional)] + pub name: Option, +} + +#[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>, + #[ts(optional)] + pub filter: Option, } #[derive(Deserialize, TS)] @@ -492,8 +844,7 @@ pub struct SystemProxySetRequest { fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result { match invocation { ActionInvocation::Global { action } => match action { - GlobalAction::ProxyStart => ctx.start().map_err(|message| RpcError { message })?, - GlobalAction::ProxyStop => ctx.stop().map_err(|message| RpcError { message })?, + GlobalAction::RecordingToggle => ctx.set_recording(!ctx.recording()), GlobalAction::FlowsClear => { ctx.clear_flows().map_err(|message| RpcError { message })?; } @@ -502,13 +853,137 @@ fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result Result { - let port = ctx.port(); - let state = if port.is_some() { ProxyState::Running } else { ProxyState::Stopped }; - Ok(GetProxyStateResponse { state, port }) + // The owner check takes a file lock and a connection, so off the async threads. + blocking(move || { + let port = ctx.port(); + let state = if port.is_some() { ProxyState::Running } else { ProxyState::Stopped }; + let settings = ctx.settings()?; + let other_owner = ctx.other_owner().map(|o| OtherOwner { + owner: o.owner, + pid: o.pid, + started_at: o.started_at, + proxy_port: o.proxy_port, + }); + Ok(GetProxyStateResponse { + state, + port, + configured_port: settings.port, + recording: ctx.recording(), + system_proxy_at_launch: settings.system_proxy_at_launch, + start_error: ctx.start_error(), + other_owner, + }) + }) + .await +} + +fn set_recording(ctx: &ProxyCtx, req: SetRecordingRequest) -> Result { + ctx.set_recording(req.enabled); + Ok(ctx.recording()) +} + +fn set_system_proxy_at_launch( + ctx: &ProxyCtx, + req: SetSystemProxyAtLaunchRequest, +) -> Result { + ctx.set_system_proxy_at_launch(req.enabled).map_err(rpc_error)?; + Ok(req.enabled) +} + +async fn take_over(ctx: ProxyCtx, _req: TakeOverRequest) -> Result { + ctx.take_over().await.map_err(rpc_error)?; + Ok(true) +} + +async fn app_icon(_ctx: ProxyCtx, req: AppIconRequest) -> Result, 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 { + 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 { + 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 { + 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 { + let flows = match req.ids { + Some(ids) => ids.iter().map(|id| flows::get(&ctx.db, id)).collect::, _>>(), + 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 { @@ -573,6 +1048,7 @@ async fn ca_trust(ctx: ProxyCtx, _req: CaTrustRequest) -> Result Result { - let (flows, total) = flows::page(&ctx.db, &req).map_err(rpc_error)?; - Ok(ListFlowsResponse { flows, total }) + let (flows, total, matching) = flows::page(&ctx.db, &req).map_err(rpc_error)?; + Ok(ListFlowsResponse { flows, total, matching }) } async fn clear_flows(ctx: ProxyCtx, _req: ClearFlowsRequest) -> Result { @@ -626,25 +1102,62 @@ async fn clear_flows(ctx: ProxyCtx, _req: ClearFlowsRequest) -> Result, response_body: Option, } -fn run_event_loop( - rx: std::sync::mpsc::Receiver, +/// Where the event loop stores flows, and whether it does. +struct FlowSink { db: ProxyQueryManager, blobs: BlobManager, events: RpcEventEmitter, -) { + attribution: Attribution, + recording: Arc>, +} + +fn run_event_loop(rx: std::sync::mpsc::Receiver, sink: FlowSink) { + let FlowSink { db, blobs, events, attribution, recording } = sink; let mut in_flight: HashMap = HashMap::new(); while let Ok(event) = rx.recv() { match event { - ProxyEvent::RequestStart { id, started_at, method, url, http_version } => { + ProxyEvent::RequestStart { + id, + started_at, + method, + url, + http_version, + connection, + client_addr, + } => { + // Paused when it started: it passes through, and none of its events are kept. + // Judged by its start rather than now, as the loop can run behind. + if !recording.lock().map(|r| r.was_on_at(started_at)).unwrap_or(true) { + continue; + } + // Begun as the connection was accepted; this covers one accepted before the + // engine's port was known. + attribution.begin(connection, client_addr); let flow_id = Flow::generate_id(); let request_body = Flow::body_id(&flow_id, FlowBody::Request); let response_body = Flow::body_id(&flow_id, FlowBody::Response); @@ -660,6 +1173,7 @@ fn run_event_loop( id, InFlight { flow, + connection, request_body: Some(BodyChunkWriter::new(blobs.clone(), request_body)), response_body: Some(BodyChunkWriter::new(blobs.clone(), response_body)), }, @@ -710,14 +1224,14 @@ fn run_event_loop( f.flow.response_truncated = truncated; f.flow.elapsed_ms = Some(clamp_ms(elapsed_ms)); f.flow.error = error; - finish_flow(&db, &events, f); + finish_flow(&db, &events, &attribution, f); } } ProxyEvent::Error { id, error, elapsed_ms } => { if let Some(mut f) = in_flight.remove(&id) { f.flow.elapsed_ms = Some(clamp_ms(elapsed_ms)); f.flow.error = Some(error); - finish_flow(&db, &events, f); + finish_flow(&db, &events, &attribution, f); } } } @@ -749,12 +1263,23 @@ fn finish_body(writer: Option) { } } -fn finish_flow(db: &ProxyQueryManager, events: &RpcEventEmitter, f: InFlight) { +fn finish_flow( + db: &ProxyQueryManager, + events: &RpcEventEmitter, + attribution: &Attribution, + f: InFlight, +) { finish_body(f.request_body); finish_body(f.response_body); let mut flow = f.flow; flow.content_type = find_header(&flow.response_headers, "content-type").map(String::from); + if let Some(app) = attribution.get(f.connection, ATTRIBUTION_WAIT) { + flow.client_pid = Some(app.pid); + flow.client_name = Some(app.name); + flow.client_bundle_id = app.bundle_id; + flow.client_path = Some(app.path); + } let written = db.with_tx(|ctx| { let (saved, created) = ctx.upsert(&flow, &UpdateSource::Background)?; // Sent while this holds the writer, before the commit, so a clear's event, sent after @@ -777,18 +1302,26 @@ define_rpc! { commands { execute_action(ActionInvocation) -> bool, format_body(FormatBodyRequest) -> String, - get_proxy_state(GetProxyStateRequest) -> GetProxyStateResponse, list_actions(ListActionsRequest) -> ListActionsResponse, list_flows(flows::FlowFilter) -> ListFlowsResponse, + set_recording(SetRecordingRequest) -> bool, + set_system_proxy_at_launch(SetSystemProxyAtLaunchRequest) -> bool, } async_commands { + app_icon(AppIconRequest) -> Option, ca_status(CaStatusRequest) -> CaStatus, ca_trust(CaTrustRequest) -> CaStatus, clear_flows(ClearFlowsRequest) -> usize, + export_har(ExportHarRequest) -> usize, filter_body(FilterBodyRequest) -> FilterBodyResponse, get_flow_body(GetFlowBodyRequest) -> GetFlowBodyResponse, + get_proxy_state(GetProxyStateRequest) -> GetProxyStateResponse, + list_workspaces(ListWorkspacesRequest) -> ListWorkspacesResponse, + preview_save(PreviewSaveRequest) -> SavePreview, + save_flow(SaveFlowToYaakRequest) -> SavedFlow, system_proxy_set(SystemProxySetRequest) -> SystemProxyStatus, system_proxy_status(SystemProxyStatusRequest) -> SystemProxyStatus, + take_over(TakeOverRequest) -> bool, } events { flows_cleared(FlowsClearedPayload), @@ -801,6 +1334,24 @@ define_rpc! { mod tests { use super::*; + #[test] + fn a_request_is_recorded_by_the_state_when_it_started() { + let mut log = RecordingLog::default(); + let before = SystemTime::now(); + std::thread::sleep(Duration::from_millis(5)); + assert!(log.set(false)); + assert!(!log.set(false), "no change"); + std::thread::sleep(Duration::from_millis(5)); + let while_paused = SystemTime::now(); + std::thread::sleep(Duration::from_millis(5)); + assert!(log.set(true)); + // Read after recording resumed, each is judged by when it started. + assert!(log.is_on()); + assert!(log.was_on_at(before)); + assert!(!log.was_on_at(while_paused)); + assert!(log.was_on_at(SystemTime::now())); + } + #[tokio::test] async fn the_app_still_hears_why_a_body_failed_to_decode() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates-proxy/yaak-proxy-lib/src/models.rs b/crates-proxy/yaak-proxy-lib/src/models.rs index 0abce3594..5bbd76931 100644 --- a/crates-proxy/yaak-proxy-lib/src/models.rs +++ b/crates-proxy/yaak-proxy-lib/src/models.rs @@ -55,6 +55,14 @@ pub struct Flow { pub error: Option, /// The id of the Yaak request this flow was last saved as. pub saved_request_id: Option, + /// The process that opened the client connection, where the platform can tell (macOS). + pub client_pid: Option, + /// Its app's name, or its executable's outside an app: "Google Chrome", "curl". + pub client_name: Option, + /// Its app's bundle id. + pub client_bundle_id: Option, + /// Its app bundle's path, or its executable's outside an app. + pub client_path: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] @@ -148,6 +156,10 @@ impl UpsertModelInfo for Flow { (ElapsedMs, self.elapsed_ms.into()), (Error, self.error.into()), (SavedRequestId, self.saved_request_id.into()), + (ClientPid, self.client_pid.into()), + (ClientName, self.client_name.into()), + (ClientBundleId, self.client_bundle_id.into()), + (ClientPath, self.client_path.into()), ]) } @@ -172,6 +184,10 @@ impl UpsertModelInfo for Flow { FlowIden::ElapsedMs, FlowIden::Error, FlowIden::SavedRequestId, + FlowIden::ClientPid, + FlowIden::ClientName, + FlowIden::ClientBundleId, + FlowIden::ClientPath, ] } @@ -202,11 +218,19 @@ impl UpsertModelInfo for Flow { elapsed_headers_ms: r.get("elapsed_headers_ms")?, elapsed_ms: r.get("elapsed_ms")?, error: r.get("error")?, - // Absent from a database read before its migrations have run. - saved_request_id: match r.get("saved_request_id") { - Err(rusqlite::Error::InvalidColumnName(_)) => None, - v => v?, - }, + saved_request_id: added_column(r, "saved_request_id")?, + client_pid: added_column(r, "client_pid")?, + client_name: added_column(r, "client_name")?, + client_bundle_id: added_column(r, "client_bundle_id")?, + client_path: added_column(r, "client_path")?, }) } } + +/// A column added by a later migration, absent from a database read before it has run. +fn added_column(r: &Row, name: &str) -> rusqlite::Result> { + match r.get(name) { + Err(rusqlite::Error::InvalidColumnName(_)) => Ok(None), + v => v, + } +} diff --git a/crates-proxy/yaak-proxy-lib/src/save.rs b/crates-proxy/yaak-proxy-lib/src/save.rs index e9e0e8131..72bf99592 100644 --- a/crates-proxy/yaak-proxy-lib/src/save.rs +++ b/crates-proxy/yaak-proxy-lib/src/save.rs @@ -7,6 +7,7 @@ use crate::convert::{self, DroppedHeader}; use crate::models::Flow; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; +use ts_rs::TS; use yaak_models::models::HttpRequest; use yaak_models::query_manager::QueryManager; use yaak_models::util::UpdateSource; @@ -17,16 +18,18 @@ pub fn client_data_dir() -> PathBuf { yaak_common::data_dir::resolve_data_dir(app_id) } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] pub struct WorkspaceSummary { pub id: String, pub name: String, pub folders: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] pub struct FolderSummary { pub id: String, pub name: String, @@ -39,7 +42,7 @@ pub struct FolderSummary { pub struct SaveFlowRequest { pub workspace_id: String, pub folder_id: Option, - /// Defaults to the URL's path. + /// None or blank leaves the request unnamed, and Yaak shows its URL. pub name: Option, } @@ -56,11 +59,16 @@ pub struct ClientDb { } impl ClientDb { + /// Yaak has run on this machine, so there is a database to save into. + pub fn exists(data_dir: &Path) -> bool { + data_dir.join("db.sqlite").exists() + } + /// Opens `db.sqlite` in Yaak's data directory `data_dir`. Fails when there is none, rather /// than creating a database for a Yaak that has never run. pub fn open(data_dir: &Path) -> Result { let db_path = data_dir.join("db.sqlite"); - if !db_path.exists() { + if !Self::exists(data_dir) { return Err(format!( "Yaak's database was not found at {}. Install Yaak and open it once.", db_path.display() diff --git a/crates-proxy/yaak-proxy-lib/src/settings.rs b/crates-proxy/yaak-proxy-lib/src/settings.rs index 3cd4ffb21..981bc3a67 100644 --- a/crates-proxy/yaak-proxy-lib/src/settings.rs +++ b/crates-proxy/yaak-proxy-lib/src/settings.rs @@ -7,15 +7,22 @@ use yaak_proxy::DEFAULT_CAPTURE_BODY_LIMIT; #[serde(default, rename_all = "camelCase")] pub struct ProxySettings { pub port: u16, - /// Whether the system proxy points at the engine while it runs. - pub system_proxy: bool, + /// Point this Mac's web traffic at the engine as it starts. It is turned off again as the + /// engine stops, and can be turned on and off any time between. Read from the earlier + /// `systemProxy` too, so a saved choice to keep it off carries over. + #[serde(alias = "systemProxy")] + pub system_proxy_at_launch: bool, /// Bytes of each request and response body stored per flow. pub capture_body_limit: usize, } impl Default for ProxySettings { fn default() -> Self { - Self { port: 9090, system_proxy: false, capture_body_limit: DEFAULT_CAPTURE_BODY_LIMIT } + Self { + port: 9090, + system_proxy_at_launch: true, + capture_body_limit: DEFAULT_CAPTURE_BODY_LIMIT, + } } } @@ -46,7 +53,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let settings = ProxySettings::load(&dir.path().join("settings.json")).unwrap(); assert_eq!(settings.port, 9090); - assert!(!settings.system_proxy); + assert!(settings.system_proxy_at_launch); assert_eq!(settings.capture_body_limit, 10 * 1024 * 1024); } @@ -63,7 +70,8 @@ mod tests { fn round_trip() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("settings.json"); - let settings = ProxySettings { port: 8080, system_proxy: true, capture_body_limit: 1024 }; + let settings = + ProxySettings { port: 8080, system_proxy_at_launch: false, capture_body_limit: 1024 }; settings.save(&path).unwrap(); assert_eq!(ProxySettings::load(&path).unwrap(), settings); @@ -71,10 +79,28 @@ mod tests { serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); assert_eq!( json, - serde_json::json!({"port": 8080, "systemProxy": true, "captureBodyLimit": 1024}) + serde_json::json!({"port": 8080, "systemProxyAtLaunch": false, "captureBodyLimit": 1024}) ); } + #[test] + fn the_earlier_system_proxy_key_carries_over() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + std::fs::write(&path, r#"{"port": 9090, "systemProxy": false}"#).unwrap(); + assert!(!ProxySettings::load(&path).unwrap().system_proxy_at_launch); + std::fs::write(&path, r#"{"systemProxy": true}"#).unwrap(); + assert!(ProxySettings::load(&path).unwrap().system_proxy_at_launch); + + // Saved again under the new key only. + let settings = ProxySettings { system_proxy_at_launch: false, ..Default::default() }; + settings.save(&path).unwrap(); + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(json["systemProxyAtLaunch"], false); + assert!(json.get("systemProxy").is_none()); + } + #[test] fn invalid_json_is_an_error() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates-proxy/yaak-proxy-lib/tests/control.rs b/crates-proxy/yaak-proxy-lib/tests/control.rs index 807b752c9..e5a402ad3 100644 --- a/crates-proxy/yaak-proxy-lib/tests/control.rs +++ b/crates-proxy/yaak-proxy-lib/tests/control.rs @@ -10,6 +10,7 @@ use hyper::server::conn::http1; use hyper::service::service_fn; use hyper::{Request, Response}; use hyper_util::rt::TokioIo; +use serde_json::{Value, json}; use tokio::net::TcpListener; use yaak_models::models::{Folder, Workspace}; use yaak_models::util::UpdateSource; @@ -45,7 +46,7 @@ async fn spawn_upstream() -> SocketAddr { /// A context over `dir` whose engine picks a free port, saving into Yaak's data in `client`. fn ctx(dir: &Path, client: &Path) -> ProxyCtx { - let settings = ProxySettings { port: 0, ..Default::default() }; + let settings = ProxySettings { port: 0, system_proxy_at_launch: false, ..Default::default() }; settings.save(&dir.join("settings.json")).unwrap(); let (events, _rx) = RpcEventEmitter::new(); ProxyCtx::new(dir, events).unwrap().with_client_data_dir(client) @@ -138,6 +139,10 @@ async fn round_trip() { assert!(client.flows(&following).await.unwrap().is_empty()); let filter = FlowFilter { status: Some("4xx".into()), ..Default::default() }; assert!(client.flows(&filter).await.unwrap().is_empty()); + let search = FlowFilter { q: Some("CHARGES post 2xx".into()), ..Default::default() }; + assert_eq!(client.flows(&search).await.unwrap().len(), 1); + let search = FlowFilter { q: Some("charges get".into()), ..Default::default() }; + assert!(client.flows(&search).await.unwrap().is_empty()); let detail = client.flow(&flow.id, 8).await.unwrap(); assert_eq!(detail.flow.method, "POST"); @@ -363,6 +368,15 @@ async fn save_writes_the_request_into_yaak() { let flow = yaak_proxy_lib::flows::get(&ctx.db, &flow.id).unwrap(); assert_eq!(flow.saved_request_id, Some(saved.request.id)); + // Without a name, through the control API as the CLI saves, it stays unnamed, as Yaak + // leaves a new request, so Yaak shows its URL. + for name in [None, Some(" ".to_string())] { + let target = SaveFlowRequest { workspace_id: workspace.id.clone(), folder_id: None, name }; + let saved = client.save(&flow.id, &target).await.unwrap(); + let request = query_manager.connect().get_http_request(&saved.request.id).unwrap(); + assert_eq!(request.name, ""); + } + let wrong = SaveFlowRequest { workspace_id: "wk_missing".into(), ..Default::default() }; assert!(client.save(&flow.id, &wrong).await.unwrap_err().contains("No workspace")); } @@ -424,3 +438,326 @@ async fn start_is_refused_while_another_process_owns_the_engine() { daemon.kill().unwrap(); daemon.wait().unwrap(); } + +async fn rpc(ctx: &ProxyCtx, cmd: &str, payload: Value) -> Result { + 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::(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::().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::().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(); +} diff --git a/crates-proxy/yaak-proxy-lib/tests/flow.rs b/crates-proxy/yaak-proxy-lib/tests/flow.rs index cb43e03c8..3fcf136ab 100644 --- a/crates-proxy/yaak-proxy-lib/tests/flow.rs +++ b/crates-proxy/yaak-proxy-lib/tests/flow.rs @@ -107,7 +107,7 @@ async fn wait_for_flow(ctx: &ProxyCtx, url: &str) -> Flow { async fn flow_is_stored_with_its_bodies() { let dir = tempfile::tempdir().unwrap(); let settings = - ProxySettings { port: 0, capture_body_limit: CAPTURE_LIMIT, ..Default::default() }; + ProxySettings { port: 0, capture_body_limit: CAPTURE_LIMIT, system_proxy_at_launch: false }; settings.save(&dir.path().join("settings.json")).unwrap(); let (events, _event_rx) = RpcEventEmitter::new(); @@ -175,7 +175,7 @@ async fn flow_is_stored_with_its_bodies() { async fn early_response_keeps_request_totals() { let dir = tempfile::tempdir().unwrap(); let settings = - ProxySettings { port: 0, capture_body_limit: CAPTURE_LIMIT, ..Default::default() }; + ProxySettings { port: 0, capture_body_limit: CAPTURE_LIMIT, system_proxy_at_launch: false }; settings.save(&dir.path().join("settings.json")).unwrap(); let (events, _event_rx) = RpcEventEmitter::new(); @@ -208,7 +208,7 @@ async fn rpc(ctx: &ProxyCtx, cmd: &str, payload: Value) -> Result async fn body_rpcs() { let dir = tempfile::tempdir().unwrap(); let settings = - ProxySettings { port: 0, capture_body_limit: CAPTURE_LIMIT, ..Default::default() }; + ProxySettings { port: 0, capture_body_limit: CAPTURE_LIMIT, system_proxy_at_launch: false }; settings.save(&dir.path().join("settings.json")).unwrap(); let (events, _event_rx) = RpcEventEmitter::new(); @@ -217,7 +217,18 @@ async fn body_rpcs() { let port = ctx.port().unwrap(); let state = rpc(&ctx, "get_proxy_state", json!({})).await.unwrap(); - assert_eq!(state, json!({"state": "running", "port": port})); + assert_eq!( + state, + json!({ + "state": "running", + "port": port, + "configuredPort": 0, + "recording": true, + "systemProxyAtLaunch": false, + "startError": null, + "otherOwner": null, + }) + ); let upstream = spawn_upstream().await; let client = reqwest::Client::builder() diff --git a/crates-tauri/yaak-app-proxy/Cargo.toml b/crates-tauri/yaak-app-proxy/Cargo.toml index a9bdbd567..b9101b213 100644 --- a/crates-tauri/yaak-app-proxy/Cargo.toml +++ b/crates-tauri/yaak-app-proxy/Cargo.toml @@ -14,12 +14,14 @@ tauri-build = { version = "2.6.1", features = [] } [features] default = ["wry"] -wry = ["tauri/wry", "tauri/x11", "tauri/dbus"] +wry = ["tauri/wry", "tauri/x11", "tauri/dbus", "tauri-plugin-dialog/gtk3"] [dependencies] log = { workspace = true } serde_json = { workspace = true } tauri = { workspace = true, features = ["devtools"] } +tauri-plugin-dialog = { workspace = true } +tauri-plugin-opener = "2.5.4" tauri-plugin-os = "2.3.2" yaak-mac-window = { workspace = true } yaak-proxy-lib = { workspace = true } diff --git a/crates-tauri/yaak-app-proxy/capabilities/default.json b/crates-tauri/yaak-app-proxy/capabilities/default.json index f4ce6abd7..f6b586f42 100644 --- a/crates-tauri/yaak-app-proxy/capabilities/default.json +++ b/crates-tauri/yaak-app-proxy/capabilities/default.json @@ -4,6 +4,9 @@ "windows": ["*"], "permissions": [ "core:default", + "dialog:allow-save", + "opener:allow-default-urls", + "opener:allow-open-url", "os:allow-os-type", "core:window:allow-close", "core:window:allow-is-fullscreen", diff --git a/crates-tauri/yaak-app-proxy/src/lib.rs b/crates-tauri/yaak-app-proxy/src/lib.rs index 15b3cb0c0..ac694c4b2 100644 --- a/crates-tauri/yaak-app-proxy/src/lib.rs +++ b/crates-tauri/yaak-app-proxy/src/lib.rs @@ -1,9 +1,7 @@ use log::{error, info, warn}; -use std::sync::Mutex; use tauri::Runtime; use tauri::{Emitter, Manager, RunEvent, State, WebviewWindow}; use yaak_proxy_lib::ProxyCtx; -use yaak_proxy_lib::control::{self, ControlServer}; use yaak_rpc::{RpcEventEmitter, RpcRouter}; use yaak_window::window::CreateWindowConfig; @@ -50,40 +48,6 @@ fn setup_window_menu(win: &WebviewWindow) { }); } -/// The control API, served while this app owns the engine. -struct Control(Mutex>); - -/// Claims the engine and serves the control API, unless a live owner (the CLI daemon) has it. -fn host_control_api(ctx: &ProxyCtx) -> Option { - // 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 = previous.and_then(|p| p.proxy_port).into_iter().collect(); - ports.extend(ctx.settings().ok().map(|s| s.port)); - let ctx = ctx.clone(); - std::thread::spawn(move || { - if let Err(e) = ctx.reset_stale_system_proxy(&ports) { - warn!("Failed to reset a stale system proxy: {e}"); - } - }); - Some(server) -} - #[tauri::command] async fn rpc( router: State<'_, RpcRouter>, @@ -97,6 +61,8 @@ async fn rpc( pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_opener::init()) .plugin(yaak_mac_window::init()) .setup(|app| { let data_dir = app.path().app_data_dir().expect("no app data dir"); @@ -104,7 +70,20 @@ pub fn run() { let (emitter, event_rx) = RpcEventEmitter::new(); let ctx = ProxyCtx::new(&data_dir, emitter)?; - app.manage(Control(Mutex::new(host_control_api(&ctx)))); + // Not while the CLI daemon owns the engine: the window offers to take it over. + match ctx.serve_control_api() { + // The engine runs for as long as the app does. A failure, such as the port + // being taken, is kept for the window to show. + Ok(()) => { + let ctx = ctx.clone(); + std::thread::spawn(move || { + if let Err(e) = ctx.start() { + warn!("Failed to start the proxy: {e}"); + } + }); + } + Err(e) => warn!("Not serving the proxy control API: {e}"), + } app.manage(ctx); app.manage(yaak_proxy_lib::build_router()); @@ -126,10 +105,9 @@ pub fn run() { .run(|app_handle, event| { if let RunEvent::Exit = event { // Never leave the Mac pointed at a proxy that is gone. - app_handle.state::().shutdown(); - if let Ok(mut control) = app_handle.state::().0.lock() { - control.take(); - } + let ctx = app_handle.state::(); + ctx.shutdown(); + ctx.stop_control_api(); return; } if let RunEvent::Ready = event { diff --git a/crates/yaak-proxy/src/connection.rs b/crates/yaak-proxy/src/connection.rs index 27776dae0..c950909dd 100644 --- a/crates/yaak-proxy/src/connection.rs +++ b/crates/yaak-proxy/src/connection.rs @@ -5,8 +5,8 @@ use hyper::service::service_fn; use hyper_util::rt::TokioIo; use tokio::io::{AsyncRead, AsyncWrite}; -use crate::Engine; use crate::request::handle_request; +use crate::{ClientConn, Engine}; pub(crate) async fn serve_http1(io: S, handler: F) -> Result<(), hyper::Error> where @@ -26,7 +26,8 @@ where pub(crate) async fn handle_connection( stream: tokio::net::TcpStream, + client: ClientConn, engine: Arc, ) -> 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 } diff --git a/crates/yaak-proxy/src/lib.rs b/crates/yaak-proxy/src/lib.rs index b0406f7de..5ac8646d7 100644 --- a/crates/yaak-proxy/src/lib.rs +++ b/crates/yaak-proxy/src/lib.rs @@ -42,8 +42,15 @@ pub struct ProxyConfig { pub capture_body_limit: usize, /// Roots trusted for upstream TLS in addition to the platform's. pub upstream_root_certs: Vec>, + /// 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, } +/// See [`ProxyConfig::on_accept`]. +pub type AcceptHook = Arc; + impl ProxyConfig { pub fn new(port: u16, ca: Arc) -> Self { Self { @@ -51,6 +58,7 @@ impl ProxyConfig { ca, capture_body_limit: DEFAULT_CAPTURE_BODY_LIMIT, upstream_root_certs: Vec::new(), + on_accept: None, } } } @@ -69,6 +77,10 @@ pub enum ProxyEvent { url: String, /// Version spoken by the client. http_version: String, + /// The client connection the request came on, numbered from 1 for each run. + connection: u64, + /// The client's end of that connection, to find the app that opened it. + client_addr: SocketAddr, }, RequestHeader { id: u64, @@ -149,6 +161,13 @@ impl ProxyHandle { self.event_rx.take() } + /// Forgets the hosts passed through after clients rejected the CA, so they are intercepted + /// again, as once the CA is trusted. + pub fn forget_passthrough(&self) { + self.engine.passthrough.lock().unwrap().clear(); + self.engine.handshake_failures.lock().unwrap().clear(); + } + /// Handshake failures counted toward passing `addr` (`host:port`) through, and whether it /// already is. For tests that must wait for the engine to record a failure. #[doc(hidden)] @@ -169,11 +188,19 @@ impl Drop for ProxyHandle { } } +/// The client connection a request came on. +#[derive(Debug, Clone, Copy)] +pub(crate) struct ClientConn { + connection: u64, + addr: SocketAddr, +} + pub(crate) struct Engine { ca: Arc, capture_body_limit: usize, events: std_mpsc::Sender, next_id: AtomicU64, + next_connection: AtomicU64, http_client: Client, upstream_tls: Arc, upstream_tls_h1: Arc, @@ -251,6 +278,7 @@ pub fn start_proxy(config: ProxyConfig) -> Result { capture_body_limit: config.capture_body_limit, events: event_tx, next_id: AtomicU64::new(1), + next_connection: AtomicU64::new(1), http_client: Client::builder(TokioExecutor::new()).build_http(), upstream_tls: upstream_tls_config(&roots, &[b"h2", b"http/1.1"])?, upstream_tls_h1: upstream_tls_config(&roots, &[b"http/1.1"])?, @@ -260,6 +288,7 @@ pub fn start_proxy(config: ProxyConfig) -> Result { }); let port = config.port; + let on_accept = config.on_accept; let handle_engine = engine.clone(); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let (ready_tx, ready_rx) = std_mpsc::channel(); @@ -289,10 +318,19 @@ pub fn start_proxy(config: ProxyConfig) -> Result { loop { tokio::select! { result = listener.accept() => match result { - Ok((stream, _)) => { + Ok((stream, addr)) => { let engine = engine.clone(); + let client = ClientConn { + connection: engine.next_connection.fetch_add(1, Ordering::Relaxed), + addr, + }; + let on_accept = on_accept.clone(); tokio::spawn(async move { - if let Err(e) = handle_connection(stream, engine).await { + if let Some(hook) = on_accept { + let (connection, addr) = (client.connection, client.addr); + let _ = tokio::task::spawn_blocking(move || hook(connection, addr)).await; + } + if let Err(e) = handle_connection(stream, client, engine).await { log::debug!("Client connection error: {e}"); } }); diff --git a/crates/yaak-proxy/src/request.rs b/crates/yaak-proxy/src/request.rs index 5bcb597ca..7427b0750 100644 --- a/crates/yaak-proxy/src/request.rs +++ b/crates/yaak-proxy/src/request.rs @@ -22,7 +22,7 @@ use tokio_rustls::{LazyConfigAcceptor, TlsConnector}; use crate::body::{CaptureBody, Direction}; use crate::connection::serve_http1; use crate::tunnel::{Rewind, splice, tunnel}; -use crate::{Engine, ProxyEvent}; +use crate::{ClientConn, Engine, ProxyEvent}; pub(crate) type BoxBody = http_body_util::combinators::BoxBody; type BoxError = Box; @@ -67,7 +67,12 @@ struct Flow { } impl Flow { - fn begin(engine: Arc, req: &Request, url: String) -> Self { + fn begin( + engine: Arc, + client: ClientConn, + req: &Request, + url: String, + ) -> Self { let flow = Self { id: engine.next_flow_id(), start: Instant::now(), engine }; flow.engine.emit(ProxyEvent::RequestStart { id: flow.id, @@ -75,6 +80,8 @@ impl Flow { method: req.method().to_string(), url, http_version: version_str(req.version()), + connection: client.connection, + client_addr: client.addr, }); for (name, value) in req.headers() { flow.engine.emit(ProxyEvent::RequestHeader { @@ -147,16 +154,17 @@ fn bad_gateway(body: String) -> Response { pub(crate) async fn handle_request( mut req: Request, + client: ClientConn, engine: Arc, ) -> Result, Infallible> { if req.method() == Method::CONNECT { - return Ok(handle_connect(req, engine)); + return Ok(handle_connect(req, client, engine)); } // Some clients send a bare path (`POST /api`) as they would to the server itself. let from_host = req.uri().authority().is_none(); let rejected = if from_host { url_from_host(&mut req, &engine).err() } else { None }; - let flow = Flow::begin(engine.clone(), &req, req.uri().to_string()); + let flow = Flow::begin(engine.clone(), client, &req, req.uri().to_string()); if let Some((status, error)) = rejected { return Ok(flow.reject(status, error)); } @@ -220,7 +228,11 @@ struct Target { addr: String, } -fn handle_connect(req: Request, engine: Arc) -> Response { +fn handle_connect( + req: Request, + client: ClientConn, + engine: Arc, +) -> Response { let Some(authority) = req.uri().authority().cloned() else { return Response::builder() .status(StatusCode::BAD_REQUEST) @@ -240,7 +252,7 @@ fn handle_connect(req: Request, engine: Arc) -> Response, engine: Arc) -> Response(mut client: S, target: Target, engine: Arc) -> Result<(), BoxError> +async fn intercept( + mut client: S, + target: Target, + conn: ClientConn, + engine: Arc, +) -> Result<(), BoxError> where S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { @@ -308,7 +325,7 @@ where let tls = Rewind::new(Bytes::from(buf), tls); let target = Arc::new(Target { server_name, ..target }); - serve_http1(tls, move |req| forward_https(req, target.clone(), engine.clone())).await?; + serve_http1(tls, move |req| forward_https(req, target.clone(), conn, engine.clone())).await?; Ok(()) } @@ -395,11 +412,12 @@ async fn connect_upstream( async fn forward_https( mut req: Request, target: Arc, + client: ClientConn, engine: Arc, ) -> Result, Infallible> { let path = req.uri().path_and_query().map(|pq| pq.to_string()).unwrap_or_else(|| "/".into()); let url = format!("https://{}{path}", target.authority); - let flow = Flow::begin(engine.clone(), &req, url.clone()); + let flow = Flow::begin(engine.clone(), client, &req, url.clone()); let client_upgrade: Option = req.headers().contains_key(UPGRADE).then(|| hyper::upgrade::on(&mut req)); diff --git a/crates/yaak-proxy/tests/proxy.rs b/crates/yaak-proxy/tests/proxy.rs index 3ed587fe6..94bd7d7f9 100644 --- a/crates/yaak-proxy/tests/proxy.rs +++ b/crates/yaak-proxy/tests/proxy.rs @@ -352,12 +352,15 @@ async fn https_get() { assert_eq!(resp.text().await.unwrap(), "hello"); let events = h.flow_events(); - let (method, req_url, req_version, started_at) = find!(events, - ProxyEvent::RequestStart { method, url, http_version, started_at, .. } => - (method.clone(), url.clone(), http_version.clone(), *started_at)); + let (method, req_url, req_version, started_at, connection, client_addr) = find!(events, + ProxyEvent::RequestStart { method, url, http_version, started_at, connection, client_addr, .. } => + (method.clone(), url.clone(), http_version.clone(), *started_at, *connection, *client_addr)); assert_eq!(method, "GET"); assert_eq!(req_url, url); assert_eq!(req_version, "HTTP/1.1"); + // The request inside the tunnel keeps the connection the CONNECT came on. + assert_eq!(connection, 1); + assert!(client_addr.ip().is_loopback() && client_addr.port() != h.proxy.port, "{client_addr}"); assert!(SystemTime::now().duration_since(started_at).unwrap() < Duration::from_secs(30)); let (status, res_version, remote_addr, headers_ms) = find!(events, @@ -635,6 +638,10 @@ async fn hang_up_after_the_handshake_passes_through() { .connect(name, h.connect_tunnel(port).await) .await .expect("two hang-ups after the handshake should pass the host through"); + + // Once the CA is trusted the host is intercepted again. + h.proxy.forget_passthrough(); + assert_eq!(h.proxy.handshake_state(&addr), (0, false)); } #[tokio::test(flavor = "multi_thread")] diff --git a/package-lock.json b/package-lock.json index 61d8b58c8..d88fc8b56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -248,6 +248,8 @@ "@tanstack/react-query": "^5.104.2", "@tanstack/react-virtual": "^3.14.17", "@tauri-apps/api": "^2.12.3", + "@tauri-apps/plugin-dialog": "^2.8.2", + "@tauri-apps/plugin-opener": "^2.7.1", "@tauri-apps/plugin-os": "^2.4.0", "@yaakapp-internal/proxy-lib": "^1.0.0", "@yaakapp-internal/theme": "^1.0.0", diff --git a/packages/ui/package.json b/packages/ui/package.json index 7018f7bfa..8ed94e9cd 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -7,7 +7,10 @@ "types": "src/index.ts", "exports": { ".": "./src/index.ts", - "./editor": "./src/editor.ts" + "./editor": "./src/editor.ts", + "./resolvedModelName": "./src/lib/resolvedModelName.ts", + "./templateTags": "./src/lib/templateTags.ts", + "./toast": "./src/lib/toast.ts" }, "sideEffects": [ "*.css" diff --git a/packages/ui/src/components/Checkbox.tsx b/packages/ui/src/components/Checkbox.tsx new file mode 100644 index 000000000..7059ff94e --- /dev/null +++ b/packages/ui/src/components/Checkbox.tsx @@ -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 ( + +
+ { + onChange(checked === "indeterminate" ? true : !checked); + }} + /> +
+ +
+
+ {!hideLabel && ( +
+ {title} +
+ )} + {help && } +
+ ); +} diff --git a/packages/ui/src/components/Dialog.tsx b/packages/ui/src/components/Dialog.tsx new file mode 100644 index 000000000..dd918f16b --- /dev/null +++ b/packages/ui/src/components/Dialog.tsx @@ -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(null); + const descriptionId = useMemo( + () => (description ? Math.random().toString(36).slice(2) : undefined), + [description], + ); + + return ( + +
{ + // NOTE: We handle Escape on the element itself so that it doesn't close multiple + // dialogs and can be intercepted by children if needed. + if (e.key === "Escape") { + if (!disableClose) onClose?.(); + e.stopPropagation(); + e.preventDefault(); + } + }} + > + + {title ? ( + + {title} + + ) : ( + + )} + + {description ? ( +
+ {description} +
+ ) : ( + + )} + +
+ {children} +
+ +
+ + {/*Put close at the end so that it's the last thing to be tabbed to*/} + {!disableClose && !hideX && ( +
+ +
+ )} + +
+ + ); +} + +const DialogFooterSlot = createContext(null); + +export interface DialogFooterAction { + label: string; + onClick?: () => void; + /** Submit the form with this id, which can be anywhere in the dialog body */ + form?: string; + color?: ButtonProps["color"]; + variant?: ButtonProps["variant"]; + disabled?: boolean; + isLoading?: boolean; + autoFocus?: boolean; + leftSlot?: ReactNode; + rightSlot?: ReactNode; +} + +/** + * The action row at the bottom of a dialog. Render it anywhere in the dialog's content and it is + * placed below the scrolling body, outside its padding. `inline` drops the divider for small + * dialogs whose body never scrolls. + */ +export function DialogFooter({ + actions, + leftSlot, + inline, +}: { + actions: DialogFooterAction[]; + leftSlot?: ReactNode; + inline?: boolean; +}) { + const slot = useContext(DialogFooterSlot); + const footer = ( +
+ {leftSlot != null &&
{leftSlot}
} + + {actions.map((action, i) => ( + + ))} + +
+ ); + return slot == null ? footer : createPortal(footer, slot); +} diff --git a/packages/ui/src/components/Icon.tsx b/packages/ui/src/components/Icon.tsx index 53f1381e7..ade16db97 100644 --- a/packages/ui/src/components/Icon.tsx +++ b/packages/ui/src/components/Icon.tsx @@ -20,6 +20,7 @@ import { ArrowUpFromLineIcon, ArrowUpIcon, BadgeCheckIcon, + BookmarkIcon, BookOpenText, BoxIcon, CakeIcon, @@ -166,6 +167,7 @@ const icons = { arrow_up_from_dot: ArrowUpFromDotIcon, arrow_up_from_line: ArrowUpFromLineIcon, badge_check: BadgeCheckIcon, + bookmark: BookmarkIcon, book_open_text: BookOpenText, box: BoxIcon, cake: CakeIcon, diff --git a/packages/ui/src/components/PlainInput.tsx b/packages/ui/src/components/PlainInput.tsx new file mode 100644 index 000000000..759d2cf2a --- /dev/null +++ b/packages/ui/src/components/PlainInput.tsx @@ -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, "inputMode" | "onKeyDownCapture" | "step"> & { + onFocusRaw?: InputHTMLAttributes["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 { + // 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(false, [forceUpdateKey]); + const textareaRef = useRef(null); + const inputRef = useRef(null); + + useImperativeHandle<{ focus: () => void } | null, { focus: () => void } | null>( + ref, + () => inputRef.current, + ); + + const handleFocus = useCallback( + (e: FocusEvent) => { + onFocusRaw?.(e); + setFocused(true); + if (autoSelect) { + inputRef.current?.select(); + textareaRef.current?.select(); + } + onFocus?.(); + }, + [autoSelect, onFocus, onFocusRaw], + ); + + const handleBlur = useCallback(() => { + setFocused(false); + onBlur?.(); + }, [onBlur]); + + // Force input to update when receiving change and not in focus + useLayoutEffect(() => { + const isFocused = document.activeElement === inputRef.current; + if (defaultValue != null && !isFocused) { + regenerateFocusedUpdateKey(); + } + }, [regenerateFocusedUpdateKey, defaultValue]); + + const id = useRef(`input-${generateId()}`); + const commonClassName = classNames( + className, + "bg-transparent! min-w-0 w-full focus:outline-hidden placeholder:text-placeholder", + "px-2 text-xs font-mono cursor-text", + ); + + const handleChange = useCallback( + (value: string) => { + onChange?.(value); + setHasChanged(true); + const isValid = (value: string) => { + if (required && !validateRequire(value)) return false; + if (typeof validate === "boolean") return validate; + if (typeof validate === "function" && !validate(value)) return false; + return true; + }; + inputRef.current?.setCustomValidity(isValid(value) ? "" : "Invalid value"); + }, + [onChange, required, setHasChanged, validate], + ); + + const wrapperRef = useRef(null); + + return ( +
+ + + {tint != null && ( +
+ )} + {leftSlot} + + 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} + /> + + {type === "password" && !hideObscureToggle && ( + setObscured((o) => !o)} + /> + )} + {rightSlot} + +
+ ); +}); + +function validateRequire(v: string) { + return v.length > 0; +} diff --git a/packages/ui/src/components/Toast.tsx b/packages/ui/src/components/Toast.tsx new file mode 100644 index 000000000..463fbd04c --- /dev/null +++ b/packages/ui/src/components/Toast.tsx @@ -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, 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 | 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 ( + +
+
+ {toastIcon && } + +
{children}
+ {action?.({ hide: onClose })} +
+
+ + {!hideDismiss && ( + + )} + + {timeout != null && !autoHideCanceled && ( +
+ +
+ )} +
+
+ ); +} diff --git a/packages/ui/src/components/Toasts.tsx b/packages/ui/src/components/Toasts.tsx new file mode 100644 index 000000000..6f1ea72e6 --- /dev/null +++ b/packages/ui/src/components/Toasts.tsx @@ -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 ( + +
+ + {toasts.map((toast: ToastInstance) => { + const { message, uniqueKey, ...props } = toast; + return ( + + hideToast(store, toast)} + > + {message} + + + ); + })} + +
+
+ ); +} diff --git a/packages/ui/src/hooks/useRandomKey.ts b/packages/ui/src/hooks/useRandomKey.ts new file mode 100644 index 000000000..9a73cf832 --- /dev/null +++ b/packages/ui/src/hooks/useRandomKey.ts @@ -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(initialValue ?? generateId()); + const regenerate = useCallback(() => setValue(generateId()), []); + return [value, regenerate] as const; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index bc7d650bf..2718478d6 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -2,11 +2,15 @@ export type { BannerProps } from "./components/Banner"; export { Banner } from "./components/Banner"; export type { ButtonProps } from "./components/Button"; export { Button } from "./components/Button"; +export type { CheckboxProps } from "./components/Checkbox"; +export { Checkbox } from "./components/Checkbox"; export type { CopyIconButtonProps } from "./components/CopyIconButton"; export { CopyIconButton } from "./components/CopyIconButton"; export { CountBadge } from "./components/CountBadge"; export type { DetailsBannerProps } from "./components/DetailsBanner"; export { DetailsBanner } from "./components/DetailsBanner"; +export type { DialogFooterAction, DialogProps } from "./components/Dialog"; +export { Dialog, DialogFooter } from "./components/Dialog"; export { DropMarker } from "./components/DropMarker"; export type { ContextMenuProps, @@ -40,6 +44,8 @@ export { KeyValueRow, KeyValueRows } from "./components/KeyValueRow"; export { Label } from "./components/Label"; export { LoadingIcon } from "./components/LoadingIcon"; export { Overlay } from "./components/Overlay"; +export type { PlainInputProps } from "./components/PlainInput"; +export { PlainInput } from "./components/PlainInput"; export { Portal } from "./components/Portal"; export type { SeparatorAction } from "./components/Separator"; export { Separator } from "./components/Separator"; @@ -68,6 +74,9 @@ export type { TabsStorage, } from "./components/tabs/Tabs"; export { TabContent, Tabs } from "./components/tabs/Tabs"; +export type { ToastProps } from "./components/Toast"; +export { Toast } from "./components/Toast"; +export { Toasts } from "./components/Toasts"; export { HttpMethodTag, HttpMethodTagRaw } from "./components/tags/HttpMethodTag"; export { formatMillis, HttpResponseDurationTag } from "./components/tags/HttpResponseDurationTag"; export { HttpStatusTag, HttpStatusTagRaw } from "./components/tags/HttpStatusTag"; @@ -104,6 +113,7 @@ export type { } from "./hooks/useHotKey"; export { formatHotkey, HotkeyProvider, useFormattedHotkey, useHotKey } from "./hooks/useHotKey"; export { useIsFullscreen } from "./hooks/useIsFullscreen"; +export { useRandomKey } from "./hooks/useRandomKey"; export { usePortal } from "./hooks/usePortal"; export { useStateWithDeps } from "./hooks/useStateWithDeps"; export type { SetStoredValue, StoredValue, UseStoredValue } from "./hooks/useStoredValue"; @@ -118,3 +128,5 @@ export { computeSideForDragMove } from "./lib/dnd"; export { docFingerprint } from "./lib/docFingerprint"; export { getNodeText } from "./lib/getNodeText"; export { minPromiseMillis } from "./lib/minPromiseMillis"; +export type { ShowToastArgs, ToastInstance } from "./lib/toast"; +export { hideToast, hideToastById, showToast, toastsAtom } from "./lib/toast"; diff --git a/packages/ui/src/lib/resolvedModelName.test.ts b/packages/ui/src/lib/resolvedModelName.test.ts new file mode 100644 index 000000000..1d9f21b9e --- /dev/null +++ b/packages/ui/src/lib/resolvedModelName.test.ts @@ -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", + ); + }); +}); diff --git a/packages/ui/src/lib/resolvedModelName.ts b/packages/ui/src/lib/resolvedModelName.ts new file mode 100644 index 000000000..996aae9b9 --- /dev/null +++ b/packages/ui/src/lib/resolvedModelName.ts @@ -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; +} diff --git a/packages/ui/src/lib/templateTags.ts b/packages/ui/src/lib/templateTags.ts new file mode 100644 index 000000000..615528d0f --- /dev/null +++ b/packages/ui/src/lib/templateTags.ts @@ -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 | 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); +} diff --git a/packages/ui/src/lib/toast.ts b/packages/ui/src/lib/toast.ts new file mode 100644 index 000000000..66f1b55df --- /dev/null +++ b/packages/ui/src/lib/toast.ts @@ -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; + +export type ToastInstance = { + id: string; + uniqueKey: string; + message: ReactNode; + timeout: 3000 | 5000 | 8000 | (number & {}) | null; + onClose?: ToastProps["onClose"]; +} & Omit; + +export type ShowToastArgs = Omit & { + id?: ToastInstance["id"]; + timeout?: ToastInstance["timeout"]; +}; + +/** The toasts on screen, which `Toasts` renders from the app's store */ +export const toastsAtom = atom([]); + +/** 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); + }); +}