diff --git a/apps/yaak-proxy/components/DecryptListEditor.tsx b/apps/yaak-proxy/components/DecryptListEditor.tsx index 779b4d323..04d107cae 100644 --- a/apps/yaak-proxy/components/DecryptListEditor.tsx +++ b/apps/yaak-proxy/components/DecryptListEditor.tsx @@ -1,56 +1,205 @@ import { Prec } from "@codemirror/state"; import { type EditorView, keymap } from "@codemirror/view"; import type { DecryptListEntry, SeenTargets } from "@yaakapp-internal/proxy-lib"; -import { CONTROL_TOOLTIP_DELAY, Icon, IconButton, Input, Tooltip } from "@yaakapp-internal/ui"; +import { + Button, + CONTROL_TOOLTIP_DELAY, + Icon, + IconButton, + Input, + SettingRow, + SettingRowBoolean, + Tooltip, +} from "@yaakapp-internal/ui"; import { filterAutocomplete } from "@yaakapp-internal/ui/editor"; import classNames from "classnames"; import { useMemo, useRef, useState } from "react"; -import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList"; +import { canTrustHere, useDecrypt, useTrustCertificate, useTrusting } from "../hooks/useDecrypt"; +import { useDecryptList } from "../hooks/useDecryptList"; import { useRpcQuery } from "../hooks/useRpcQuery"; -import { decryptFilterOptions, entryLabel, normalizeEntry } from "../lib/decrypt"; +import { + decryptFilterOptions, + decryptsEverything, + entryLabel, + entrySubject, + EVERYTHING, + everythingEdit, + normalizeEntry, +} from "../lib/decrypt"; +import { revealPath } from "../lib/opener"; +import { useShowToast } from "../lib/toast"; /** - * What HTTPS is decrypted: one row per entry, the user's in order and then the built-in - * exclusions, each removable, and an input that adds one, suggesting `host:`, `app:` and `!`, - * then the hosts and apps seen in the capture. Saves as it changes. Placed by Settings' Proxy - * tab. + * Settings' Decryption section: the certificate, Decrypt everything as a switch over `*`, then + * the person's own hosts and apps in order, each removable, an input that adds one (suggesting + * `host:`, `app:` and `!`, then the hosts and apps seen), and the built-in exclusions folded + * into one row. Every change goes through Decrypt, so the first that decrypts something trusts + * the certificate first. */ -export function DecryptListEditor({ className }: { className?: string }) { +export function DecryptionSettings() { const list = useDecryptList(); - const save = useEditDecryptList(); + const { apply } = useDecrypt(); + const [busy, setBusy] = useState(false); // Read when the input opens its suggestions, so new traffic shows without rebuilding it. const seen = useRpcQuery("list_seen", {}, { staleTime: 0, refetchOnMount: "always" }); const seenRef = useRef(seen.data); seenRef.current = seen.data; + const everything = decryptsEverything(list); + const own = list.filter((e) => !e.builtIn && e.entry !== EVERYTHING); + const builtIns = list.filter((e) => e.builtIn); - // Each change replaces the whole list, so one waits for the last to land rather than undo it. - const remove = (entry: string) => { - if (save.isPending) return; - save.mutate({ add: [], remove: [entry] }); + // One change at a time, each applied to the list as it is when it lands. + const change = async ( + edit: { add: string[]; remove: string[] }, + message: string, + sub?: string, + ) => { + if (busy) return; + setBusy(true); + try { + await apply({ edit, message, sub }); + } finally { + setBusy(false); + } }; + const remove = (entry: string) => + void change( + { add: [], remove: [entry] }, + entry.startsWith("!") + ? `Stopped excluding ${entrySubject(entry.slice(1))}` + : `Stopped decrypting ${entrySubject(entry)}`, + ); return ( -
- save.mutate({ add: [entry], remove: [] })} + <> + + + void change( + everythingEdit(on), + on ? "Decrypting everything" : "Stopped decrypting everything", + on ? "Except Apple services and apps that pin" : undefined, + ) + } /> -
- {list.length === 0 && ( -
Nothing decrypted
+
+
+ Hosts and apps + New connections +
+ + void change( + { add: [entry], remove: [] }, + entry.startsWith("!") + ? `Excluding ${entrySubject(entry.slice(1))}` + : `Decrypting ${entrySubject(entry)}`, + ) + } + /> +
+ {own.length === 0 && ( +
Nothing listed
+ )} + {own.map((e) => ( + remove(e.entry)} /> + ))} + {builtIns.length > 0 && } +
+
+ + ); +} + +/** The certificate: trusted, with Reveal for devices and tools with their own stores, or Trust */ +function CertificateRow() { + const caStatus = useRpcQuery("ca_status", {}); + const trust = useTrustCertificate(); + const trusting = useTrusting(); + const showToast = useShowToast(); + const status = caStatus.data; + if (status == null) + return ( + + {null} + + ); + return ( + +
+ {status.trusted ? ( + + + Trusted + + ) : ( + Not trusted )} - {list.map((e) => ( + {!status.trusted && canTrustHere() ? ( + + ) : ( + + )} +
+
+ ); +} + +/** The built-in exclusions, folded into one row that opens to each, with its kind */ +function BuiltIns({ + entries, + disabled, + onRemove, +}: { + entries: DecryptListEntry[]; + disabled: boolean; + onRemove: (entry: string) => void; +}) { + const [open, setOpen] = useState(false); + const kinds = new Set(entries.map((e) => e.kind)); + const summary = [kinds.has("apple") ? "Apple" : null, kinds.has("pinned") ? "pinned apps" : null] + .filter((k) => k != null) + .join(", "); + return ( + <> + + {open && + entries.map((e) => ( remove(e.entry)} + disabled={disabled} + onRemove={() => onRemove(e.entry)} /> ))} -
-
+ ); } @@ -81,12 +230,10 @@ function EntryRow({ {entryLabel(entry.entry)} - {entry.builtIn && ( - - - built-in - - + {entry.builtIn && entry.kind != null && ( + + {entry.kind === "apple" ? "Apple" : "Pinned"} + )} setError(null)} /> diff --git a/apps/yaak-proxy/components/EncryptedCard.tsx b/apps/yaak-proxy/components/EncryptedCard.tsx new file mode 100644 index 000000000..d9be5359f --- /dev/null +++ b/apps/yaak-proxy/components/EncryptedCard.tsx @@ -0,0 +1,274 @@ +import { formatSize } from "@yaakapp-internal/lib/formatSize"; +import type { Flow } from "@yaakapp-internal/proxy-lib"; +import { + Button, + Dropdown, + formatMillis, + Icon, + IconButton, + type IconProps, + InlineCode, +} from "@yaakapp-internal/ui"; +import classNames from "classnames"; +import { type ReactNode, useEffect, useState } from "react"; +import { canTrustHere, useDecrypt, usePassthrough, useTrustCertificate } from "../hooks/useDecrypt"; +import { useDecryptList } from "../hooks/useDecryptList"; +import { useRpcMutation } from "../hooks/useRpcMutation"; +import { useRpcQuery } from "../hooks/useRpcQuery"; +import { + type CardAction, + connectionOf, + encryptedCard, + isOpen, + isRejection, +} from "../lib/encryptedCard"; +import { useFlowActions } from "../lib/flowActions"; +import { utcTimestamp } from "../lib/flowViews"; +import { useShowToast } from "../lib/toast"; +import { AppIcon } from "./AppIcon"; +import { useCopyEnvironment } from "./ProxyStatus"; + +/** + * The card for HTTPS the proxy did not decrypt, or a client's refused handshake: the flow card's + * header lines, with ENCRYPTED or REJECTED where the status goes, then why in one line and the + * changes that would decrypt the next connection like it, each showing the entry it writes. + */ +export function EncryptedCard({ flow }: { flow: Flow }) { + const list = useDecryptList(); + const caStatus = useRpcQuery("ca_status", {}); + const passthrough = usePassthrough(); + const rejection = isRejection(flow); + // A tunnel passed through after a rejection names the client that refused. + const rejectedById = flow.tlsRejected == null ? flow.tunnelRejectedBy : null; + const rejectedBy = useRpcQuery( + "get_flows", + { ids: rejectedById == null ? [] : [rejectedById] }, + { enabled: rejectedById != null }, + ).data?.[0]; + const model = encryptedCard({ + flow, + list, + trusted: caStatus.data?.trusted ?? null, + canTrust: canTrustHere(), + passthrough, + rejectedBy, + }); + const { apply, trusting } = useDecrypt(); + const trust = useTrustCertificate(); + const copyEnvironment = useCopyEnvironment(); + const showToast = useShowToast(); + const fail = (err: Error) => + showToast({ id: "encrypted-card", color: "danger", message: err.message }); + const forget = useRpcMutation("forget_passthrough", { onError: fail }); + const reconnect = useRpcMutation("reconnect_tunnels", { onError: fail }); + const connection = connectionOf(flow); + const [busy, setBusy] = useState(null); + + const run = async (action: CardAction) => { + switch (action.kind) { + case "decrypt": + case "stop": + if (action.edit == null) return; + setBusy(action.label); + await apply({ + edit: action.edit, + message: action.toast?.message ?? action.label, + sub: action.toast?.sub, + }); + setBusy(null); + return; + case "reconnect": + reconnect.mutate({ flowId: flow.id }); + return; + case "trust": + trust.mutate({}); + return; + case "try_again": + forget.mutate({ address: connection.address }); + return; + case "copy_environment": + await copyEnvironment(); + forget.mutate({ address: connection.address }); + return; + } + }; + + return ( +
+
+ + + {connection.host.includes(":") ? `[${connection.host}]` : connection.host} + :{connection.port} + +
+ +
+
+
+ {model.why} + {model.whyEntry != null && ( + <> + {" "} + {model.whyEntry} + + )} + {model.whySub != null && · {model.whySub}} +
+ {model.fact != null && ( +
{model.fact}
+ )} + {model.actions.map((action) => ( + void run(action)} + /> + ))} + {model.foot.length > 0 && ( +
{model.foot.join(" · ")}
+ )} + {flow.error != null && flow.tlsRejected == null && ( +
{flow.error}
+ )} +
+
+
+ ); +} + +const ICONS: Record, IconProps["icon"]> = { + host: "globe", + everything: "layers", + lock: "lock", + lock_open: "lock_open", + refresh: "refresh", + shield: "shield", + terminal: "square_terminal", +}; + +function ActionButton({ + action, + flow, + loading, + disabled, + onClick, +}: { + action: CardAction; + flow: Flow; + loading: boolean; + disabled: boolean; + onClick: () => void; +}) { + const icon: ReactNode = + action.icon === "app" ? ( + flow.clientPath == null ? ( + + ) : ( + + ) + ) : ( + + ); + return ( + + ); +} + +/** ENCRYPTED or REJECTED, the duration, or how long it has been open, and the bytes each way */ +function StatusLine({ flow, rejected }: { flow: Flow; rejected: boolean }) { + const flowActions = useFlowActions(); + const open = isOpen(flow); + const openFor = useOpenFor(flow, open); + return ( +
+
+ + {rejected ? "REJECTED" : "ENCRYPTED"} + + • + {open ? ( + open {openFor == null ? "" : formatMillis(openFor)} + ) : ( + <> + {flow.elapsedMs == null ? "--" : formatMillis(flow.elapsedMs)} + • + + + {formatSize(flow.requestSize)} + + {formatSize(flow.responseSize)} + + + )} +
+
+ + + +
+
+ ); +} + +/** How long an open tunnel has been open, ticking each second */ +function useOpenFor(flow: Flow, open: boolean): number | null { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!open) return; + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, [open]); + const started = Date.parse(utcTimestamp(flow.startedAt)); + return Number.isFinite(started) ? Math.max(0, now - started) : null; +} diff --git a/apps/yaak-proxy/components/FirstRun.tsx b/apps/yaak-proxy/components/FirstRun.tsx index 81484f6a9..3c31f8e57 100644 --- a/apps/yaak-proxy/components/FirstRun.tsx +++ b/apps/yaak-proxy/components/FirstRun.tsx @@ -1,18 +1,8 @@ -import { - Button, - CONTROL_TOOLTIP_DELAY, - Icon, - InlineCode, - LoadingIcon, - Tooltip, -} from "@yaakapp-internal/ui"; +import { Button, Icon, InlineCode, LoadingIcon } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtomValue } from "jotai"; import type { ReactNode } from "react"; -import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList"; import { useRpcMutation } from "../hooks/useRpcMutation"; -import { everythingEdit } from "../lib/decrypt"; -import { decryptStep } from "../lib/firstRun"; import { encryptingPercent } from "../lib/encrypting"; import { proxyStateAtom } from "../lib/store"; import { useCopyEnvironment, useProxySetup, useSystemProxySwitch } from "./ProxyStatus"; @@ -20,36 +10,27 @@ import { useCopyEnvironment, useProxySetup, useSystemProxySwitch } from "./Proxy type StepState = "todo" | "next" | "wait" | "done" | "fail"; /** - * The list's empty state until traffic arrives: trust the certificate, turn on the system proxy, - * choose what to decrypt, 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. + * The list's empty state until traffic arrives: turn on the system proxy, then see traffic. The + * certificate is trusted at the first Decrypt, not here, as nothing needs it before. 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 { systemProxy, setSystemProxy } = setup; const systemSwitch = useSystemProxySwitch(setup); const execute = useRpcMutation("execute_action"); const retry = useRpcMutation("take_over"); const copyEnvironment = useCopyEnvironment(); - const decryptList = useDecryptList(); - const saveDecryptList = useEditDecryptList(); 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 @@ -57,10 +38,6 @@ export function FirstRun() { : systemSwitch.failed ? "fail" : "next"; - const decrypt = decryptStep(decryptList, trusted); - const everything = decrypt.choice === "everything"; - const setEverything = (on: boolean) => saveDecryptList.mutate(everythingEdit(on)); - // The certificate is optional here: nothing needs it until something is decrypted. const ready = systemOn || !systemSupported; const encrypting = !running && !otherOwner ? encryptingPercent(proxyState) : null; const trafficState: StepState = @@ -71,39 +48,14 @@ export function FirstRun() {
Set up Yaak Proxy
- {systemSupported ? "Four" : "Three"} steps, once. Then every app on this Mac sends its - traffic here. + {systemSupported + ? "Two steps, once. Then every app on this Mac sends its traffic here." + : "Traffic appears here as apps send it."}
- trust.mutate({})} - > - {trustState === "fail" ? "Try Again" : "Trust Certificate"} - - ) : null - } - /> {systemSupported && ( )} - Everything - - ) : ( - - Pick as they appear - - ) - } - action={ - everything ? ( - - ) : ( - - ) - } - /> - ( + () => [{ tab: { value: REQUEST_TAB_INFO, label: "Info" }, content: }], + [flow], + ); + const contentType = findHeader(flow.requestHeaders, "content-type"); + const badge = bodyTypeBadge(contentType, flow.requestSize, flow.graphqlOperation != null); + const bodyTab = useMemo(() => bodyTabItem(REQUEST_TAB_BODY, badge), [badge]); const available = [ + REQUEST_TAB_INFO, REQUEST_TAB_BODY, REQUEST_TAB_PARAMS, REQUEST_TAB_HEADERS, @@ -169,8 +176,11 @@ function FlowRequestHalf({ flow }: { flow: Flow }) { request={request} fullHeight forceUpdateKey={forceUpdateKey} - tabsStorageKey="proxy_request_tabs" + // Info joined as the first tab: an order dragged before it would put it last + tabsStorageKey="proxy_request_tabs.2" bodyLoader={loader} + bodyTab={bodyTab} + leadingTabs={leadingTabs} extraTabs={extraTabs} defaultTab={defaultTab} activeTab={activeTab} @@ -208,6 +218,15 @@ function FlowRequestHalf({ flow }: { flow: Flow }) { ); } +/** A half's body tab: Body, with the body's type beside it as the Headers count is, if any */ +function bodyTabItem(value: string, badge: string | null): TabItem { + return { + value, + label: "Body", + rightSlot: badge == null ? null : , + }; +} + /** The method and URL, at the status line's height so the two header lines align */ function UrlLine({ flow }: { flow: Flow }) { return ( @@ -244,14 +263,11 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string } const flowActions = useFlowActions(); const messagesLabel = messagesTabLabel(flow); + const badge = bodyTypeBadge(flow.contentType, flow.responseSize); + const bodyTab = useMemo(() => bodyTabItem(RESPONSE_TAB_BODY, badge), [badge]); const extraTabs = useMemo( - () => [ - { - tab: { value: RESPONSE_TAB_TIMELINE, label: "Timeline" }, - content: , - }, - { tab: { value: RESPONSE_TAB_INFO, label: "Info" }, content: }, - ...(messagesLabel == null + () => + messagesLabel == null ? [] : [ { @@ -269,16 +285,13 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string } }, content: , }, - ]), - ], + ], [flow, messagesLabel], ); const available = [ RESPONSE_TAB_BODY, RESPONSE_TAB_HEADERS, RESPONSE_TAB_COOKIES, - RESPONSE_TAB_TIMELINE, - RESPONSE_TAB_INFO, ...(messagesLabel == null ? [] : [RESPONSE_TAB_MESSAGES]), ]; const { activeTab, defaultTab, onTabChange } = useShapeTab("response", flow, available); @@ -299,6 +312,7 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string } activeTab={activeTab} onTabChange={onTabChange} extraTabs={extraTabs} + bodyTab={bodyTab} renderBodyViewer={() => } statusLineCenter={ flow.responseTruncated ? ( diff --git a/apps/yaak-proxy/components/FlowDetail.tsx b/apps/yaak-proxy/components/FlowDetail.tsx index 3a2edbf90..f87526c19 100644 --- a/apps/yaak-proxy/components/FlowDetail.tsx +++ b/apps/yaak-proxy/components/FlowDetail.tsx @@ -2,9 +2,10 @@ import classNames from "classnames"; import { useAtomValue } from "jotai"; import type { CSSProperties } from "react"; import { selectedFlowAtom } from "../lib/atoms"; +import { isRejection } from "../lib/encryptedCard"; +import { EncryptedCard } from "./EncryptedCard"; import { EmptyState } from "./FlowBodyViewer"; import { FlowCard } from "./FlowCard"; -import { NotDecrypted } from "./NotDecrypted"; /** * The detail pane: the selected flow's card, or what the pane holds before one is selected. The @@ -35,8 +36,8 @@ export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideB
- ) : flow.tunnelled ? ( - + ) : flow.tunnelled || isRejection(flow) ? ( + ) : ( )} diff --git a/apps/yaak-proxy/components/FlowInfo.tsx b/apps/yaak-proxy/components/FlowInfo.tsx index 7286729d9..83b1081c3 100644 --- a/apps/yaak-proxy/components/FlowInfo.tsx +++ b/apps/yaak-proxy/components/FlowInfo.tsx @@ -1,16 +1,26 @@ import { formatSize } from "@yaakapp-internal/lib/formatSize"; import type { Flow } from "@yaakapp-internal/proxy-lib"; -import { DetailsBanner, formatMillis, KeyValueRow, KeyValueRows } from "@yaakapp-internal/ui"; +import { + CONTROL_TOOLTIP_DELAY, + DetailsBanner, + formatMillis, + Icon, + type IconProps, + KeyValueRow, + KeyValueRows, + Tooltip, +} from "@yaakapp-internal/ui"; import { format } from "date-fns"; -import type { HTMLAttributes, ReactElement, ReactNode } from "react"; +import { Fragment, type HTMLAttributes, type ReactElement, type ReactNode, useMemo } from "react"; import { utcTimestamp } from "../lib/flowViews"; +import { flowPhases, type PhaseKind } from "../lib/phases"; import { folderOf } from "../lib/search"; import { AppIcon } from "./AppIcon"; /** * Where a flow came from and how it travelled: in a proxy you didn't send the request, so the * connection on each leg, the app and its process, timing, sizes and the flow's record are part - * of the answer. + * of the answer. Then the timeline: each phase of the exchange and how long it took. */ export function FlowInfo({ flow }: { flow: Flow }) { const folder = folderOf(flow); @@ -72,10 +82,55 @@ export function FlowInfo({ flow }: { flow: Flow }) { row("saved", "Saved As", flow.savedRequestId, { copy: true }), ]} +
); } +const PHASE_ICONS: Record = { + start: { icon: "send_horizontal", color: "secondary" }, + step: { icon: "arrow_right", color: "secondary" }, + end: { icon: "check", color: "success" }, + error: { icon: "alert_triangle", color: "danger" }, + cancelled: { icon: "circle_off", color: "secondary" }, +}; + +/** The flow's phases, each with how long it took and when, from the start */ +function Timeline({ flow }: { flow: Flow }) { + const phases = useMemo(() => flowPhases(flow), [flow]); + if (phases.length === 0) return null; + return ( + Timeline}> +
+ {phases.map((p) => { + const { icon, color } = PHASE_ICONS[p.kind]; + return ( + + + {p.detail == null ? ( + {p.label} + ) : ( + + + {p.label} + {` ${p.detail}`} + + + )} + + {p.kind === "start" ? "" : `+${formatMillis(p.tookMs)}`} + + + {formatMillis(p.atMs)} + + + ); + })} +
+
+ ); +} + /** A row as `KeyValueRows` takes it */ type Row = ReactElement>; diff --git a/apps/yaak-proxy/components/FlowList.tsx b/apps/yaak-proxy/components/FlowList.tsx index 6a79643a1..a1f1b4043 100644 --- a/apps/yaak-proxy/components/FlowList.tsx +++ b/apps/yaak-proxy/components/FlowList.tsx @@ -55,6 +55,7 @@ import { compareFlows } from "../lib/flowWindow"; import { countNewFlows, isAtEnd, resumesFollowing } from "../lib/follow"; import { applyFilter, goToPlace } from "../lib/navigation"; import { asOneTerm } from "../lib/query"; +import { connectionOf } from "../lib/encryptedCard"; import { folderOf } from "../lib/search"; import { columnPrefsAtom, @@ -853,6 +854,9 @@ const FlowRow = memo(function FlowRow({ // A failure's reason, or Cancelled, takes the type and size cells together, side by side. const typeAt = ids.indexOf("type"); const spanReason = noResponse && typeAt !== -1 && ids[typeAt + 1] === "size"; + // An encrypted connection has no path, so its host takes the room when Path is beside it. + const hostAt = ids.indexOf("host"); + const spanHost = flow.tunnelled && hostAt !== -1 && ids[hostAt + 1] === "path"; const sinceArrival = arrivedAt == null ? null : Date.now() - arrivedAt; return ( @@ -873,8 +877,8 @@ const FlowRow = memo(function FlowRow({ : {}), }} className={classNames( - "absolute top-0 left-1.5 right-1.5 grid items-center px-1.5 rounded-md whitespace-nowrap", - "tabular-nums", + "group/row absolute top-0 left-1.5 right-1.5 grid items-center px-1.5 rounded-md", + "whitespace-nowrap tabular-nums", flow.cancelled && "text-text-subtlest", selected ? "text-text bg-surface-highlight group-focus-within/flows:bg-surface-active" @@ -885,10 +889,16 @@ const FlowRow = memo(function FlowRow({ {columns.map((c) => { if (spanReason && c.id === "size") return null; + if (spanHost && c.id === "path") return null; return ( // Names the cell's column for its menu, without a box of its own in the row's grid
- +
); })} @@ -896,22 +906,41 @@ const FlowRow = memo(function FlowRow({ ); }); +/** Why an encrypted row is, for its mark's tooltip */ +function encryptedReason(flow: Flow): string { + switch (flow.tunnelReason) { + case "excluded": + return flow.tunnelExcludedBuiltIn || flow.tunnelExcludedBy == null + ? "Encrypted: excluded by default" + : `Encrypted: excluded by ${flow.tunnelExcludedBy}`; + case "rejected": + return "Encrypted: passed through after a client rejected the certificate"; + default: + return "Encrypted: not on the decrypt list"; + } +} + /** - * One mark per row, by a fixed priority: failed, cancelled, not decrypted, streaming, cut, saved. - * Its tooltip lists every state the flow has. Agent reads join it once the engine records them. + * One mark per row, by a fixed priority: rejected, failed, cancelled, encrypted, streaming, cut, + * saved. Its tooltip lists every state the flow has. Agent reads join it once the engine records + * them. */ function FlowMark({ flow }: { flow: Flow }) { const open = isOpenStream(flow); + const rejected = flow.tlsRejected != null; const states: string[] = []; - if (flow.error != null) states.push(`Failed: ${flow.error}`); + if (rejected) states.push("Rejected the proxy's certificate"); + else if (flow.error != null) states.push(`Failed: ${flow.error}`); if (flow.cancelled) states.push("Cancelled by the app"); - if (flow.tunnelled) states.push("Not decrypted"); + if (flow.tunnelled) states.push(encryptedReason(flow)); if (open) states.push("Streaming"); if (flow.responseTruncated) states.push("Body cut at the capture limit"); if (flow.savedRequestId != null) states.push("Saved to Yaak"); const mark = ( - {flow.error != null ? ( + {rejected ? ( + + ) : flow.error != null ? ( ) : flow.cancelled ? ( @@ -958,7 +987,122 @@ function isOpenStream(flow: Flow): boolean { return flow.streamKind != null && flow.elapsedMs == null && flow.error == null && !flow.cancelled; } -function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span: boolean }) { +interface CellProps { + column: ColumnId; + flow: Flow; + /** Takes the next cell's room too */ + span: boolean; + selected: boolean; +} + +/** + * An encrypted connection's cell: quiet, and only what is known, so its host, how much it + * carried and for how long; nothing read from inside it, so no method, path, status, type, + * query or field. Null for the cells that show as on any row. + */ +function encryptedCell({ column, flow, span, selected }: CellProps): ReactElement | null { + const quiet = classNames(cell, mono, "text-text-subtlest"); + switch (column) { + case "number": + case "started": + return null; + case "app": + if (flow.clientPath == null) return ; + return ( + + + + + + ); + case "host": { + const { label } = connectionOf(flow); + const colon = label.lastIndexOf(":"); + const port = colon === -1 || label.endsWith("]") ? null : label.slice(colon); + const host = port == null ? label : label.slice(0, colon); + return ( + + + + {host} + {port != null && {port}} + + + + ); + } + case "size": + return ( + + {isOpenTunnel(flow) ? "" : formatSize(flow.responseSize)} + + ); + case "requestSize": + return ( + + {isOpenTunnel(flow) ? "" : formatSize(flow.requestSize)} + + ); + case "time": { + const ms = isOpenTunnel(flow) + ? Date.now() - Date.parse(utcTimestamp(flow.startedAt)) + : flow.elapsedMs; + return ( + + {ms == null || !Number.isFinite(ms) ? "" : formatMillis(Math.max(0, ms))} + + ); + } + default: + return ; + } +} + +/** + * A refused handshake's cell: its host as any row's, the refusal across Type and Size in + * warning, and nothing for the request it never made. Null for the cells that show as on any + * row. + */ +function rejectedCell({ column, span }: CellProps): ReactElement | null { + switch (column) { + case "method": + case "path": + case "status": + case "query": + case "size": + return ; + case "type": + return ( + + Rejected certificate + + ); + default: + return null; + } +} + +function FlowCell(props: CellProps) { + const { column, flow, span } = props; + const special = flow.tunnelled + ? encryptedCell(props) + : flow.tlsRejected != null + ? rejectedCell(props) + : null; + if (special != null) return special; const failed = flow.error != null; const noResponse = (failed || flow.cancelled) && flow.status == null; switch (column) { @@ -1005,10 +1149,6 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span: ); } case "path": { - // A tunnel's only path is its host; what it carried is unread. - if (flow.tunnelled) { - return Not decrypted; - } const path = pathOf(flow.url); if (flow.streamKind == null) { return ( @@ -1034,7 +1174,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span: ); } case "status": - return (flow.cancelled || flow.tunnelled) && flow.status == null ? ( + return flow.cancelled && flow.status == null ? ( -- ) : ( @@ -1104,7 +1244,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span: ); case "query": { - const query = flow.tunnelled ? "" : (urlParts(flow.url).query ?? ""); + const query = urlParts(flow.url).query ?? ""; return ( {query} diff --git a/apps/yaak-proxy/components/FlowListEmpty.tsx b/apps/yaak-proxy/components/FlowListEmpty.tsx index 1b71d059f..839d79de4 100644 --- a/apps/yaak-proxy/components/FlowListEmpty.tsx +++ b/apps/yaak-proxy/components/FlowListEmpty.tsx @@ -6,7 +6,13 @@ import { appliedFilterAtom } from "../lib/atoms"; import { applyFilter } from "../lib/navigation"; import { rpc } from "../lib/rpc"; import { activeQuery, widerQueries } from "../lib/query"; -import { hideSystemAtom, pausedAtAtom, proxyStateAtom, searchWindowAtom } from "../lib/store"; +import { + hideEncryptedAtom, + hideSystemAtom, + pausedAtAtom, + proxyStateAtom, + searchWindowAtom, +} from "../lib/store"; /** Nothing to list: no flows yet, recording paused, or a query nothing matches */ export function FlowListEmpty() { @@ -64,6 +70,7 @@ export function PausedRow({ style }: { style?: React.CSSProperties }) { */ function NoResults({ text }: { text: string }) { const hideSystem = useAtomValue(hideSystemAtom); + const hideEncrypted = useAtomValue(hideEncryptedAtom); const store = useStore(); const setText = (wider: string) => applyFilter(store, wider); const [wider, setWider] = useState<{ text: string; count: number }[]>([]); @@ -73,7 +80,7 @@ function NoResults({ text }: { text: string }) { const candidates = widerQueries(text); void Promise.all( candidates.map(async (candidate) => { - const query = activeQuery(candidate, hideSystem); + const query = activeQuery(candidate, hideSystem, hideEncrypted); const res = await rpc("list_flows", { ...query, limit: 0 }); return { text: candidate, count: query == null ? res.total : (res.matching ?? res.total) }; }), @@ -87,7 +94,7 @@ function NoResults({ text }: { text: string }) { return () => { current = false; }; - }, [text, hideSystem]); + }, [text, hideSystem, hideEncrypted]); return ( = { - start: { icon: "send_horizontal", color: "secondary" }, - step: { icon: "arrow_right", color: "secondary" }, - end: { icon: "check", color: "success" }, - error: { icon: "alert_triangle", color: "danger" }, - cancelled: { icon: "circle_off", color: "secondary" }, -}; - -/** Yaak's event list with the flow's phases as rows, each saying how long it took */ -export function FlowTimeline({ flow }: { flow: Flow }) { - const phases = useMemo(() => flowPhases(flow), [flow]); - return ( - - events={phases} - getEventKey={(p) => p.key} - splitLayoutStorageKey="proxy_flow_timeline" - enableKeyboardNav={false} - emptyMessage="No timings recorded" - renderRow={({ event, isActive, onClick }) => { - const { icon, color } = ICONS[event.kind]; - return ( - } - content={ - event.detail == null ? ( - {event.label} - ) : ( - - - {event.label} - {` ${event.detail}`} - - - ) - } - columns="auto minmax(0,1fr) 6rem 6rem" - cells={{ - trailing: [ - - {event.kind === "start" ? "" : `+${formatMillis(event.tookMs)}`} - , - - {formatMillis(event.atMs)} - , - ], - }} - /> - ); - }} - /> - ); -} diff --git a/apps/yaak-proxy/components/HttpsStrip.tsx b/apps/yaak-proxy/components/HttpsStrip.tsx new file mode 100644 index 000000000..632aa594e --- /dev/null +++ b/apps/yaak-proxy/components/HttpsStrip.tsx @@ -0,0 +1,174 @@ +import { + Button, + CONTROL_TOOLTIP_DELAY, + Dropdown, + type DropdownItem, + Icon, + IconButton, + Tooltip, +} from "@yaakapp-internal/ui"; +import { useAtomValue } from "jotai"; +import { useMemo, useState } from "react"; +import { useLocalStorage } from "react-use"; +import { canTrustHere, useDecrypt, useTrustCertificate, useTrusting } from "../hooks/useDecrypt"; +import { useDecryptList } from "../hooks/useDecryptList"; +import { useRpcQuery } from "../hooks/useRpcQuery"; +import { everythingEdit, listEdit, setDecrypted } from "../lib/decrypt"; +import { chooseOptions, connectionCount, stripState } from "../lib/httpsStrip"; +import { sidebarFlowsAtom } from "../lib/store"; +import { AppIcon } from "./AppIcon"; +import { SETTINGS_TAB_PROXY, useOpenSettings } from "./Settings"; + +const STRIP = + "flex items-center gap-2.5 h-[2.2rem] mx-2 mb-1 pl-3 pr-1.5 rounded-md bg-surface-highlight/45 border border-border-subtle text-sm text-text-subtle min-w-0"; + +/** + * Above the list while nothing is decrypted: says once that HTTPS is encrypted, and offers + * Decrypt Everything, or Choose… for apps and sites one at a time. Goes once anything is on + * the list, or when dismissed; comes back, with Trust, while the list decrypts something the + * certificate isn't trusted for. + */ +export function HttpsStrip() { + const list = useDecryptList(); + const flows = useAtomValue(sidebarFlowsAtom); + const caStatus = useRpcQuery("ca_status", {}); + const [dismissed, setDismissed] = useLocalStorage("https_strip_dismissed", false); + // Kept while Choose… is open, so checking a row there leaves the menu where it is. + const [choosing, setChoosing] = useState(false); + const { apply } = useDecrypt(); + const trusting = useTrusting(); + const trust = useTrustCertificate(); + const openSettings = useOpenSettings(); + const encrypted = useMemo(() => flows.filter((f) => f.tunnelled).length, [flows]); + const state = stripState({ + list, + encrypted, + dismissed: dismissed ?? false, + trusted: caStatus.data?.trusted ?? null, + canTrust: canTrustHere(), + }); + const options = useMemo(() => chooseOptions(flows, list), [flows, list]); + + if (state === "hidden" && !choosing) return null; + + if (state === "broken") { + return ( +
+ + Certificate not trusted + Required to decrypt + +
+ ); + } + + const items: DropdownItem[] = [ + { type: "separator", label: "Apps", hidden: options.apps.length === 0 }, + ...options.apps.map((app): DropdownItem => ({ + label: + app.path == null ? ( + app.name + ) : ( + + + {app.name} + + ), + keepOpenOnSelect: true, + leftSlot: , + rightSlot: , + onSelect: async () => { + const subject = { kind: "app", name: app.name, bundleId: app.bundleId } as const; + await apply({ + edit: listEdit(list, setDecrypted(list, [subject], !app.checked)), + message: `${app.checked ? "Stopped decrypting" : "Decrypting"} ${app.name}`, + }); + }, + })), + { type: "separator", label: "Sites", hidden: options.sites.length === 0 }, + ...options.sites.map((site): DropdownItem => ({ + label: *.{site.site}, + keepOpenOnSelect: true, + leftSlot: , + rightSlot: , + onSelect: async () => { + const subject = { kind: "site", site: site.site } as const; + await apply({ + edit: listEdit(list, setDecrypted(list, [subject], !site.checked)), + message: `${site.checked ? "Stopped decrypting" : "Decrypting"} *.${site.site}`, + }); + }, + })), + { type: "separator" }, + { + label: "Edit List in Settings…", + leftSlot: , + onSelect: () => openSettings(SETTINGS_TAB_PROXY), + }, + ]; + return ( +
+ + HTTPS is encrypted + + {connectionCount(encrypted)} + +
+ + + + setChoosing(true)} onClose={() => setChoosing(false)}> + + + setDismissed(true)} + /> +
+
+ ); +} + +function Check({ on }: { on: boolean }) { + return on ? ( + + ) : ( + + ); +} + +function Count({ n }: { n: number }) { + return {n.toLocaleString()}; +} diff --git a/apps/yaak-proxy/components/NotDecrypted.tsx b/apps/yaak-proxy/components/NotDecrypted.tsx deleted file mode 100644 index b45e4bd8f..000000000 --- a/apps/yaak-proxy/components/NotDecrypted.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { formatSize } from "@yaakapp-internal/lib/formatSize"; -import type { Flow } from "@yaakapp-internal/proxy-lib"; -import { Button, CONTROL_TOOLTIP_DELAY, formatMillis, Icon, Tooltip } from "@yaakapp-internal/ui"; -import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList"; -import { type DecryptSubject, detailActions, listEdit, setDecrypted } from "../lib/decrypt"; -import { sidebarPath } from "../lib/search"; -import { EmptyState } from "./FlowBodyViewer"; - -/** - * The detail of HTTPS passed through undecrypted: its sizes and time, and buttons that add its - * host or its app to the decrypt list, from the next connection on. - */ -export function NotDecrypted({ flow }: { flow: Flow }) { - const list = useDecryptList(); - const save = useEditDecryptList(); - const host = sidebarPath(flow.url).split("/")[0] ?? flow.url; - const hostSubject: DecryptSubject = { kind: "host", host }; - const appSubject: DecryptSubject | null = - flow.clientName == null - ? null - : { kind: "app", name: flow.clientName, bundleId: flow.clientBundleId }; - const app = flow.clientName; - const actions = detailActions( - list, - host, - app == null ? null : { name: app, bundleId: flow.clientBundleId }, - ); - const open = flow.elapsedMs == null && flow.error == null; - - const decrypt = (subject: DecryptSubject) => - save.mutate(listEdit(list, setDecrypted(list, [subject], true))); - - const facts = [ - `${formatSize(Number(flow.requestSize))} sent`, - `${formatSize(Number(flow.responseSize))} received`, - open ? "open" : flow.elapsedMs == null ? null : formatMillis(flow.elapsedMs), - ].filter((f) => f != null); - - return ( - -
-
- - Not decrypted -
-
{facts.join(" · ")}
- {flow.error != null &&
{flow.error}
} - {actions.listed ? ( - -
Decrypting new connections
-
- ) : actions.blocked != null ? ( - -
- Excluded by {actions.blocked} -
-
- ) : ( -
- {actions.host && ( - - )} - {actions.app && appSubject != null && ( - - )} -
- )} -
-
- ); -} diff --git a/apps/yaak-proxy/components/ProxyLayout.tsx b/apps/yaak-proxy/components/ProxyLayout.tsx index a6fc701a2..3649615d9 100644 --- a/apps/yaak-proxy/components/ProxyLayout.tsx +++ b/apps/yaak-proxy/components/ProxyLayout.tsx @@ -31,6 +31,7 @@ import { SavedSearchDialog } from "./SavedSearchDialog"; import { SearchBar } from "./SearchBar"; import { SettingsDialogFallback, useOpenSettings } from "./Settings"; import { Sidebar } from "./Sidebar"; +import { HttpsStrip } from "./HttpsStrip"; import { TakeOverBanner } from "./TakeOverBanner"; export function ProxyLayout() { @@ -166,6 +167,7 @@ export function ProxyLayout() { >
+
diff --git a/apps/yaak-proxy/components/ProxyStatus.tsx b/apps/yaak-proxy/components/ProxyStatus.tsx index e4b524544..a2f6b67c8 100644 --- a/apps/yaak-proxy/components/ProxyStatus.tsx +++ b/apps/yaak-proxy/components/ProxyStatus.tsx @@ -1,5 +1,5 @@ import { useQueryClient } from "@tanstack/react-query"; -import type { CaStatus, SystemProxyStatus } from "@yaakapp-internal/proxy-lib"; +import type { SystemProxyStatus } from "@yaakapp-internal/proxy-lib"; import { Button, Checkbox, @@ -13,10 +13,13 @@ import classNames from "classnames"; import { useAtom, useAtomValue } from "jotai"; import type { HTMLAttributes, ReactNode, Ref } from "react"; import { useEffect, useRef, useState } from "react"; +import { useDecrypt, useTrustCertificate } from "../hooks/useDecrypt"; +import { useDecryptList } from "../hooks/useDecryptList"; import { useRpcEvent } from "../hooks/useRpcEvent"; import { useRpcMutation } from "../hooks/useRpcMutation"; import { useRpcQuery } from "../hooks/useRpcQuery"; import { helperRegisteredAtAtom } from "../lib/atoms"; +import { decryptsAnything, decryptsEverything, everythingEdit, listSummary } from "../lib/decrypt"; import { encryptingPercent } from "../lib/encrypting"; import { environmentExports } from "../lib/env"; import { proxyStatusOf } from "../lib/proxyStatus"; @@ -27,9 +30,10 @@ import { useShowToast } from "../lib/toast"; /** * The header's state pill and its menu: the listening address, which copies itself, the state, - * Pause or Resume Recording, Override 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. Everything set once and left alone lives in Settings. + * Pause or Resume Recording, Override system proxy, and Decrypt everything. 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 while something is listed to decrypt steps out of the menu beside the pill + * until it is. Everything set once and left alone lives in Settings. */ export function ProxyStatus() { const dropdownRef = useRef(null); @@ -38,14 +42,16 @@ export function ProxyStatus() { const setup = useProxySetup(); const execute = useRpcMutation("execute_action"); const retry = useRpcMutation("take_over"); - const { caStatus, trust, systemProxy, setSystemProxy } = setup; + const { caStatus, systemProxy, setSystemProxy } = setup; const systemSwitch = useSystemProxySwitch(setup); const running = proxyState?.state === "running"; 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 decryptList = useDecryptList(); + // Something is listed to decrypt but the certificate isn't trusted, so it can't be. + const broken = caStatus.data != null && !caStatus.data.trusted && decryptsAnything(decryptList); // The same status the header's tint takes its colour from const status = proxyStatusOf(proxyState); const encrypting = status === "encrypting" ? encryptingPercent(proxyState) : null; @@ -166,44 +172,7 @@ export function ProxyStatus() { /> ), }, - { - type: "content", - label: caStatus.data?.trusted ? ( - } - title="Certificate trusted" - description="Yaak Proxy CA, in your login keychain." - /> - ) : ( - } - title="Certificate not trusted" - description={ - trust.error != null ? ( - {trust.error.message} - ) : trust.isPending ? ( - "Waiting for your password in the macOS prompt." - ) : ( - "So HTTPS reads as plain text. macOS asks for your login password once." - ) - } - action={ - - } - /> - ), - }, + { type: "content", label: }, ]; return ( @@ -235,7 +204,7 @@ export function ProxyStatus() { - {untrusted && ( + {broken && ( dropdownRef.current?.open()}> Certificate not trusted @@ -245,6 +214,73 @@ export function ProxyStatus() { ); } +/** + * Decrypt everything, `*` on the list, as a switch: what else is listed when off, and Trust + * while something is listed and the certificate isn't trusted. + */ +function DecryptEverythingRow({ broken }: { broken: boolean }) { + const list = useDecryptList(); + const { apply, trusting } = useDecrypt(); + const trust = useTrustCertificate(); + const on = decryptsEverything(list); + if (broken) { + return ( + } + title="Decrypt everything" + description={Certificate not trusted} + action={ + + } + /> + ); + } + return ( + } + title="Decrypt everything" + description={ + trusting + ? "Waiting for macOS" + : on + ? "Except Apple services and apps that pin" + : (listSummary(list) ?? "Nothing listed") + } + action={ + + + void apply( + checked + ? { + edit: everythingEdit(true), + message: "Decrypting everything", + sub: "Except Apple services and apps that pin", + } + : { edit: everythingEdit(false), message: "Stopped decrypting everything" }, + ) + } + /> + + } + /> + ); +} + /** * The certificate and the system proxy, read once and shared by the header and the first-run * card, with the actions that change them. @@ -265,10 +301,6 @@ export function useProxySetup() { // The user comes back from System Settings, maybe having just allowed the helper. const { refetch: refetchSystemProxy } = systemProxy; useEffect(() => onWindowFocus(() => void refetchSystemProxy()), [refetchSystemProxy]); - 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) => { @@ -284,7 +316,7 @@ export function useProxySetup() { useEffect(() => { if (on && !everOn) setEverOn(true); }, [on, everOn, setEverOn]); - return { caStatus, trust, systemProxy, setSystemProxy }; + return { caStatus, systemProxy, setSystemProxy }; } /** The system proxy switch's state, from where it points and the change in flight */ diff --git a/apps/yaak-proxy/components/SettingsProxy.tsx b/apps/yaak-proxy/components/SettingsProxy.tsx index 41ee662c4..1838d4e7f 100644 --- a/apps/yaak-proxy/components/SettingsProxy.tsx +++ b/apps/yaak-proxy/components/SettingsProxy.tsx @@ -40,7 +40,7 @@ import { helperRow } from "../lib/systemProxy"; import { proxyStateAtom, refreshProxyState } from "../lib/store"; import { getOsType } from "../lib/tauri"; import { useShowToast } from "../lib/toast"; -import { DecryptListEditor } from "./DecryptListEditor"; +import { DecryptionSettings } from "./DecryptListEditor"; /** * The proxy's own settings tab: how it listens and the system proxy, what it decrypts, what @@ -89,7 +89,7 @@ export function SettingsProxy() { - + diff --git a/apps/yaak-proxy/components/Sidebar.tsx b/apps/yaak-proxy/components/Sidebar.tsx index cef3aecfa..1d5f6d8b5 100644 --- a/apps/yaak-proxy/components/Sidebar.tsx +++ b/apps/yaak-proxy/components/Sidebar.tsx @@ -10,21 +10,30 @@ import { import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; import { atomWithStorage } from "jotai/utils"; import { type PointerEvent, type SyntheticEvent, useCallback, useEffect } from "react"; -import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList"; +import { useDecrypt } from "../hooks/useDecrypt"; +import { useDecryptList } from "../hooks/useDecryptList"; import { useRpcMutation } from "../hooks/useRpcMutation"; import { appliedFilterAtom, SIDEBAR_TREE_ID } from "../lib/atoms"; -import { blockerOf, decryptMenuAction, isDecrypted, listEdit, setDecrypted } from "../lib/decrypt"; +import { + blockerOf, + decryptMenuAction, + decryptsEverything, + everythingEdit, + listEdit, + setDecrypted, +} from "../lib/decrypt"; import { revealLabel, revealPath } from "../lib/opener"; import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse"; import { copyRowsText, + ENCRYPTED_ID, filterWithRows, type SidebarItem, SYSTEM_ID, valueRows, } from "../lib/sidebarTree"; import { applyFilter, noteSidebarInput, noteSidebarMenu } from "../lib/navigation"; -import { hideSystemAtom, sidebarTreeAtom } from "../lib/store"; +import { hideEncryptedAtom, hideSystemAtom, sidebarTreeAtom } from "../lib/store"; import { getOsType } from "../lib/tauri"; import { useShowToast } from "../lib/toast"; import { AppIcon } from "./AppIcon"; @@ -73,7 +82,7 @@ function ItemInner({ item }: { item: SidebarItem }) { return (
{item.kind === "all" && } - {item.kind === "tunnelled" && } + {item.kind === "encrypted" && } {item.kind === "saved" && } {item.kind === "folder" && } {item.appPath != null && } @@ -88,7 +97,11 @@ function ItemInner({ item }: { item: SidebarItem }) { {item.sublabel != null && ( {item.sublabel} )} - {item.decrypt != null && } + {item.encryptedOnly && ( + + + + )}
); @@ -117,14 +130,12 @@ function CloseAtOnce({ onClose }: { onClose: () => void }) { return null; } -/** A small open lock on a host, site or app the decrypt list decrypts */ -function DecryptedMark({ item }: { item: SidebarItem }) { - const list = useDecryptList(); - if (item.decrypt == null || !isDecrypted(list, item.decrypt)) return null; - return ( - - - +/** A checkable menu item's mark: a check when on, the same room when off */ +function checkMark(on: boolean) { + return on ? ( + + ) : ( + ); } @@ -137,9 +148,9 @@ export function Sidebar() { [], ); const [hideSystem, setHideSystem] = useAtom(hideSystemAtom); + const [hideEncrypted, setHideEncrypted] = useAtom(hideEncryptedAtom); const decryptList = useDecryptList(); - const setDecryptList = useEditDecryptList(); - const { mutate: saveDecryptList } = setDecryptList; + const { apply: applyDecrypt } = useDecrypt(); const showToast = useShowToast(); const setSavedDialog = useSetAtom(savedSearchDialogAtom); const { mutate: deleteSaved } = useRpcMutation("delete_saved_query", { @@ -210,15 +221,35 @@ export function Sidebar() { return [ { label: "Show in All Traffic", - leftSlot: hideSystem ? ( - - ) : ( - - ), + leftSlot: checkMark(!hideSystem), onSelect: () => setHideSystem(!hideSystem), }, ]; } + if (items.length === 1 && items[0]?.id === ENCRYPTED_ID) { + return withSeparators([ + [ + { + label: "Decrypt Everything", + leftSlot: , + hidden: decryptsEverything(decryptList), + onSelect: () => + void applyDecrypt({ + edit: everythingEdit(true), + message: "Decrypting everything", + sub: "Except Apple services and apps that pin", + }), + }, + ], + [ + { + label: "Show in All Traffic", + leftSlot: checkMark(!hideEncrypted), + onSelect: () => setHideEncrypted(!hideEncrypted), + }, + ], + ]); + } const paths = items.length === 1 ? pathItems(items[0]) : []; const saved = items.flatMap((i) => (i.savedId == null ? [] : [{ ...i, id: i.savedId }])); if (saved.length > 0) { @@ -265,7 +296,10 @@ export function Sidebar() { // Only what this changes, applied to the list as it is then, so a second change // made before this one lands keeps it. const next = setDecrypted(decryptList, subjects, action.on); - saveDecryptList(listEdit(decryptList, next)); + void applyDecrypt({ + edit: listEdit(decryptList, next), + message: `${action.on ? "Decrypting" : "Stopped decrypting"} ${action.subject}`, + }); }, }, ]; @@ -273,7 +307,7 @@ export function Sidebar() { }, [ decryptList, - saveDecryptList, + applyDecrypt, showToast, setSavedDialog, deleteSaved, @@ -281,6 +315,8 @@ export function Sidebar() { filterItems, hideSystem, setHideSystem, + hideEncrypted, + setHideEncrypted, ], ); const renderContextMenu = useCallback["renderContextMenu"]>>( diff --git a/apps/yaak-proxy/harness/backend.ts b/apps/yaak-proxy/harness/backend.ts index 1ec7bc83d..e70f4392c 100644 --- a/apps/yaak-proxy/harness/backend.ts +++ b/apps/yaak-proxy/harness/backend.ts @@ -1,4 +1,5 @@ import type { + DecryptListEntry, Flow, FlowSortKey, GetProxyStateResponse, @@ -6,16 +7,59 @@ import type { } from "@yaakapp-internal/proxy-lib"; import { sortFlows } from "../lib/columns"; import { appIconPng, fixtures } from "./fixtures"; +import { BUILT_IN_EXCLUSIONS, httpsFlows } from "./httpsFixtures"; + +const params = new URLSearchParams(location.search); + +/** + * The decrypt list and the certificate as the harness holds them, from `?list=` (`none`, + * `host`, `everything`, `apps`) and `?ca=untrusted`. `?trust=cancel` cancels the password + * prompt; `?still=n` is how many open connections each change leaves encrypted. + */ +const https = { + list: initialList(params.get("list")), + trusted: params.get("ca") !== "untrusted", + passthrough: params.get("scene") === "mixed" ? ["sync.acme.dev:443"] : ([] as string[]), +}; + +function initialList(which: string | null): DecryptListEntry[] { + const own = (...entries: string[]) => entries.map((entry) => ({ entry, builtIn: false })); + switch (which) { + case "none": + return BUILT_IN_EXCLUSIONS; + case "host": + return [...own("host:www.google.com"), ...BUILT_IN_EXCLUSIONS]; + case "everything": + return [...own("*"), ...BUILT_IN_EXCLUSIONS]; + case "apps": + return [...own("app:Dia", "app:Google Chrome", "app:Acme Sync"), ...BUILT_IN_EXCLUSIONS]; + case "excluded": + return [...own("*", "!host:*.slack.com"), ...BUILT_IN_EXCLUSIONS]; + default: + return [ + { entry: "host:api.stripe.com", builtIn: false }, + { entry: "app:Google Chrome", builtIn: false }, + { entry: "!host:*.apple.com", builtIn: true }, + ]; + } +} /** * Dev only: answers the engine's RPCs the harness pages need, from fixtures, so the card and * settings render outside the app as they do inside it. */ export function installFixtureBackend() { + // `?firstrun`: setup not done yet, so the first-run card shows + if (params.has("firstrun")) localStorage.setItem("system_proxy_ever_on", "false"); + else if (params.has("window")) localStorage.setItem("system_proxy_ever_on", "true"); const internals = (window as unknown as { __TAURI_INTERNALS__: Record }) .__TAURI_INTERNALS__; // For checking the window as traffic arrives: `__harnessEmit("model_write", payload)` (window as unknown as { __harnessEmit: typeof emit }).__harnessEmit = emit; + // Unlistening, which the app does as its components unmount + ( + window as unknown as { __TAURI_EVENT_PLUGIN_INTERNALS__: unknown } + ).__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: () => {} }; internals.transformCallback = (callback: (event: unknown) => void) => { const id = callbacks.size + 1; callbacks.set(id, callback); @@ -138,11 +182,16 @@ function matches(flow: Flow, query: string): boolean { if (field === "host") return host.toLowerCase().includes(value.replaceAll("*", "")); if (field === "method") return flow.method.toLowerCase() === value; if (field === "status") return String(flow.status ?? "").startsWith(value.replace(/x+$/, "")); + if (field === "is" && value === "encrypted") return flow.tunnelled; + if (field === "is" && value === "rejected") return flow.tlsRejected != null; return field == null ? text.includes(term) : true; }); } function allFlows(): Flow[] { + if (params.has("firstrun")) return []; + const scene = httpsFlows(params.get("scene")); + if (scene != null) return scene.map((f, i) => ({ ...f, number: i + 1 })); const many = Number(new URLSearchParams(location.search).get("many") ?? 0); return (many > 0 ? manyFlows(many) : fixtures.map((f) => f.flow)).map((f, i) => ({ ...f, @@ -150,7 +199,15 @@ function allFlows(): Flow[] { })); } -function answer(cmd: string, payload: unknown): unknown { +function caStatus() { + return { + trusted: https.trusted, + installed: https.trusted, + certPath: "/Users/you/Library/Application Support/app.yaak.proxy/ca.pem", + }; +} + +async function answer(cmd: string, payload: unknown): Promise { const req = (payload ?? {}) as Record; switch (cmd) { case "get_flows": { @@ -231,14 +288,45 @@ function answer(cmd: string, payload: unknown): unknown { case "get_storage_size": return { bytes: 412 * 1024 * 1024 }; case "ca_status": + return caStatus(); + case "ca_trust": { + // A moment for the prompt, so the controls that asked show it + await new Promise((r) => setTimeout(r, Number(params.get("prompt") ?? 900))); + if (params.get("trust") === "cancel") return { ...caStatus(), cancelled: true }; + https.trusted = true; + return { ...caStatus(), cancelled: false }; + } + case "edit_decrypt_list": { + const { add = [], remove = [] } = req as { add?: string[]; remove?: string[] }; + const own = https.list.filter((e) => !e.builtIn && !remove.includes(e.entry)); + for (const entry of add) { + if (!own.some((e) => e.entry === entry)) own.push({ entry, builtIn: false }); + } + const builtIns = BUILT_IN_EXCLUSIONS.filter( + (e) => https.list.some((l) => l.entry === e.entry) && !remove.includes(e.entry), + ); + https.list = [...own, ...builtIns]; + emit("decrypt_list_changed", { list: { entries: https.list } }); + const decrypts = add.some((e) => !e.startsWith("!")) || remove.some((e) => e.startsWith("!")); return { - trusted: true, - installed: true, - certPath: "/Users/you/Library/Application Support/app.yaak.proxy/ca.pem", + entries: https.list, + stillEncrypted: decrypts ? Number(params.get("still") ?? 3) : 0, }; + } + case "list_passthrough": + return { addresses: https.passthrough }; + case "forget_passthrough": { + const address = String(req.address); + const forgotten = https.passthrough.includes(address); + https.passthrough = https.passthrough.filter((a) => a !== address); + emit("passthrough_changed", { addresses: https.passthrough }); + return { forgotten, addresses: https.passthrough }; + } + case "reconnect_tunnels": + return { closed: 1 }; case "system_proxy_status": return { - enabled: true, + enabled: !params.has("firstrun"), port: 9090, services: [], backend: "helper", @@ -246,13 +334,7 @@ function answer(cmd: string, payload: unknown): unknown { supported: true, }; case "get_decrypt_list": - return { - entries: [ - { entry: "host:api.stripe.com", builtIn: false }, - { entry: "app:Google Chrome", builtIn: false }, - { entry: "!host:*.apple.com", builtIn: true }, - ], - }; + return { entries: https.list }; case "list_actions": // As the engine lists them on macOS return { @@ -270,7 +352,8 @@ function answer(cmd: string, payload: unknown): unknown { case "list_flows": { const all = allFlows(); const query = typeof req.query === "string" ? req.query : ""; - const matching = all.filter((f) => matches(f, query)); + const hideEncrypted = req.hideEncrypted === true && !query.includes("@is:encrypted"); + const matching = all.filter((f) => matches(f, query) && !(hideEncrypted && f.tunnelled)); // In a sort's order, as the database lists it, all in one page const sort = (req.sort ?? []) as FlowSortKey[]; const flows = sort.length > 0 ? sortFlows(matching, sort) : matching.reverse(); @@ -278,7 +361,7 @@ function answer(cmd: string, payload: unknown): unknown { return { flows: page, total: all.length, - matching: query === "" ? null : matching.length, + matching: query === "" && !hideEncrypted ? null : matching.length, cursor: null, }; } diff --git a/apps/yaak-proxy/harness/httpsFixtures.ts b/apps/yaak-proxy/harness/httpsFixtures.ts new file mode 100644 index 000000000..4fa7a659c --- /dev/null +++ b/apps/yaak-proxy/harness/httpsFixtures.ts @@ -0,0 +1,172 @@ +import type { DecryptListEntry, Flow } from "@yaakapp-internal/proxy-lib"; +import { fixtures } from "./fixtures"; + +/** + * Dev only: traffic for HTTPS decryption's states, by `?scene=`: `encrypted`, the first minute + * with nothing decrypted, and `mixed`, some decrypted, a refused handshake and the tunnel passed + * through after it. + */ +export type HttpsScene = "encrypted" | "mixed"; + +export const BUILT_IN_EXCLUSIONS: DecryptListEntry[] = [ + { entry: "!host:*.apple.com", builtIn: true, kind: "apple" }, + { entry: "!host:*.icloud.com", builtIn: true, kind: "apple" }, + { entry: "!host:*.mzstatic.com", builtIn: true, kind: "apple" }, + { entry: "!host:*.1password.com", builtIn: true, kind: "pinned" }, + { entry: "!host:*.dropbox.com", builtIn: true, kind: "pinned" }, +]; + +const template = fixtures[0]?.flow as Flow; +const start = Date.parse("2026-11-09T09:00:00Z"); + +const apps = { + dia: { + clientName: "Dia", + clientBundleId: "company.thebrowser.dia", + clientPath: "/Applications/Dia.app", + }, + chrome: { + clientName: "Google Chrome", + clientBundleId: "com.google.Chrome", + clientPath: "/Applications/Google Chrome.app", + }, + slack: { + clientName: "Slack", + clientBundleId: "com.tinyspeck.slackmacgap", + clientPath: "/Applications/Slack.app", + }, + onePassword: { + clientName: "1Password", + clientBundleId: "com.1password.1password", + clientPath: "/Applications/1Password.app", + }, + acme: { + clientName: "Acme Sync", + clientBundleId: "dev.acme.sync", + clientPath: "/Applications/Acme Sync.app", + }, + cloudd: { + clientName: "cloudd", + clientBundleId: null, + clientPath: "/System/Library/PrivateFrameworks/CloudKitDaemon.framework/Support/cloudd", + }, +} as const; + +type App = (typeof apps)[keyof typeof apps]; + +function tunnel(i: number, host: string, app: App, over: Partial = {}): Flow { + return { + ...template, + ...app, + id: `fl_https_${i}`, + method: "CONNECT", + url: `${host}:443`, + startedAt: new Date(start + i * 1100).toISOString().replace("Z", ""), + status: null, + contentType: null, + requestHeaders: [], + responseHeaders: [], + elapsedHeadersMs: null, + elapsedConnectMs: null, + elapsedTlsMs: null, + elapsedRequestMs: null, + elapsedMs: 120 + ((i * 97) % 900), + clientHttpVersion: "HTTP/1.1", + upstreamHttpVersion: null, + upstreamTlsVersion: null, + upstreamTlsCipher: null, + requestSize: 600 + ((i * 331) % 3000), + responseSize: 1_200 + ((i * 7919) % 120_000), + tunnelled: true, + tunnelReason: "not_listed", + ...over, + // An open tunnel has been open a little while, counting up from now + ...(over.elapsedMs === null + ? { startedAt: new Date(Date.now() - 22_000 - i * 1000).toISOString().replace("Z", "") } + : {}), + }; +} + +function decrypted(i: number, source: number, url: string, app: App): Flow { + const flow = fixtures[source]?.flow ?? template; + return { + ...flow, + ...app, + // The fixture's id first, so the backend finds its bodies + id: `${flow.id}~https_${i}`, + url, + startedAt: new Date(start + i * 1100).toISOString().replace("Z", ""), + }; +} + +const builtInExcluded = { + tunnelReason: "excluded" as const, + tunnelExcludedBy: "!host:*.1password.com", + tunnelExcludedBuiltIn: true, +}; + +/** The first minute: every HTTPS connection encrypted, two still open */ +function encryptedScene(): Flow[] { + return [ + tunnel(1, "clientstream.launchdarkly.com", apps.dia, { elapsedMs: null }), + tunnel(2, "browser-intake-datadoghq.com", apps.dia), + tunnel(3, "api.typefully.com", apps.dia), + tunnel(4, "js.stripe.com", apps.dia), + tunnel(5, "edgeapi.slack.com", apps.slack), + tunnel(6, "wss-primary.slack.com", apps.slack, { elapsedMs: null }), + tunnel(7, "gateway.icloud.com", apps.cloudd, { + tunnelReason: "excluded", + tunnelExcludedBy: "!host:*.icloud.com", + tunnelExcludedBuiltIn: true, + }), + tunnel(8, "my.1password.com", apps.onePassword, builtInExcluded), + tunnel(9, "www.google.com", apps.dia), + tunnel(10, "fonts.gstatic.com", apps.dia), + tunnel(11, "clients2.google.com", apps.chrome), + tunnel(12, "home.taila714c.ts.net:8317", apps.dia, { url: "home.taila714c.ts.net:8317" }), + ]; +} + +/** + * Some decrypted, a refused handshake from an app that pins, and the tunnel passed through + * after it + */ +function mixedScene(): Flow[] { + return [ + tunnel(1, "my.1password.com", apps.onePassword, builtInExcluded), + decrypted(2, 0, "https://api.stripe.com/v1/payment_intents", apps.dia), + decrypted(3, 5, "https://api.github.com/graphql", apps.chrome), + tunnel(4, "edgeapi.slack.com", apps.slack), + decrypted(5, 2, "https://cdn.example.com/avatars/42.png?size=2", apps.dia), + tunnel(6, "gateway.icloud.com", apps.cloudd, { + tunnelReason: "excluded", + tunnelExcludedBy: "!host:*.icloud.com", + tunnelExcludedBuiltIn: true, + }), + { + ...tunnel(7, "sync.acme.dev", apps.acme), + method: "", + url: "https://sync.acme.dev", + tunnelled: false, + tunnelReason: null, + tlsRejected: "bad_certificate", + error: "Rejected the proxy's certificate", + elapsedMs: 6, + requestSize: 517, + responseSize: 0, + }, + tunnel(8, "sync.acme.dev", apps.acme, { + tunnelReason: "rejected", + tunnelRejectedBy: "fl_https_7", + elapsedMs: null, + }), + decrypted(9, 0, "https://api.typefully.com/v1/drafts", apps.dia), + tunnel(10, "www.google.com", apps.dia, { elapsedMs: null }), + ]; +} + +export function httpsFlows(scene: string | null): Flow[] | null { + if (scene === "encrypted") return encryptedScene(); + if (scene === "mixed") return mixedScene(); + return null; +} diff --git a/apps/yaak-proxy/hooks/useDecrypt.tsx b/apps/yaak-proxy/hooks/useDecrypt.tsx new file mode 100644 index 000000000..39d5da10e --- /dev/null +++ b/apps/yaak-proxy/hooks/useDecrypt.tsx @@ -0,0 +1,172 @@ +import { useIsMutating, useQueryClient } from "@tanstack/react-query"; +import type { CaStatus, PassthroughResponse } from "@yaakapp-internal/proxy-lib"; +import { Button } from "@yaakapp-internal/ui"; +import { useCallback } from "react"; +import { inverseEdit } from "../lib/decrypt"; +import { applyChoice, choiceToast, type DecryptChoice, type ListEdit } from "../lib/decryptChoice"; +import { rpc } from "../lib/rpc"; +import { getOsType } from "../lib/tauri"; +import { useShowToast } from "../lib/toast"; +import { announceEngineChange } from "../lib/windowSync"; +import { useRpcEvent } from "./useRpcEvent"; +import { useRpcMutation } from "./useRpcMutation"; +import { useRpcQuery } from "./useRpcQuery"; + +const CA_KEY = ["ca_status", {}]; +const LIST_KEY = ["get_decrypt_list", {}]; +const PASSTHROUGH_KEY = ["list_passthrough", {}]; +const TRUST_KEY = ["ca_trust"]; + +/** The app trusts its certificate itself only on macOS; elsewhere it is installed by hand */ +export function canTrustHere(): boolean { + return getOsType() === "macos"; +} + +/** Whether macOS's password prompt is up, from any control that asked for it */ +export function useTrusting(): boolean { + return useIsMutating({ mutationKey: TRUST_KEY }) > 0; +} + +/** Asks macOS to trust the certificate, the status read from its answer */ +export function useTrustCertificate() { + const queryClient = useQueryClient(); + return useRpcMutation("ca_trust", { + mutationKey: TRUST_KEY, + onSuccess: ({ trusted, installed, certPath }) => + queryClient.setQueryData(CA_KEY, { trusted, installed, certPath }), + }); +} + +/** + * Every Decrypt and Stop Decrypting in the app: trusts the certificate first when the change can + * decrypt something and it isn't yet (macOS's password prompt), then writes the change, then a + * toast with Undo, and with Reconnect while open connections the list now decrypts stay + * encrypted. A cancelled prompt changes nothing and says nothing. + */ +export function useDecrypt() { + const queryClient = useQueryClient(); + const showToast = useShowToast(); + const trusting = useTrusting(); + const { mutateAsync: trust } = useTrustCertificate(); + + const run = useCallback( + async (edit: ListEdit) => { + try { + return await applyChoice( + { + canTrust: canTrustHere(), + readTrusted: async () => { + const status = await rpc("ca_status", {}); + queryClient.setQueryData(CA_KEY, status); + return status.trusted; + }, + trust: () => trust({}), + save: async (edit) => { + try { + return await rpc("edit_decrypt_list", edit); + } finally { + // Read again rather than taken from the answer, which can land after a newer + // change's event and would put the older list back. + void queryClient.invalidateQueries({ queryKey: LIST_KEY }); + announceEngineChange(); + } + }, + }, + edit, + ); + } catch (err) { + showToast({ + id: "decrypt", + color: "danger", + message: err instanceof Error ? err.message : String(err), + }); + return null; + } + }, + [queryClient, showToast, trust], + ); + + const reconnect = useCallback( + (entries: string[]) => { + for (const entry of entries) { + rpc("reconnect_tunnels", { entry }).catch((err) => + showToast({ id: "reconnect", color: "danger", message: String(err) }), + ); + } + }, + [showToast], + ); + + /** Applies a choice; true once it is written */ + const apply = useCallback( + async (choice: DecryptChoice): Promise => { + const result = await run(choice.edit); + if (result == null || result.status === "cancelled") return false; + if (result.status === "untrusted") { + showToast({ + id: "decrypt", + color: "warning", + message: "Certificate not trusted", + }); + return false; + } + const toast = choiceToast(choice, result.stillEncrypted); + showToast({ + id: "decrypt", + message: ( +
+
{toast.message}
+ {toast.sub != null && ( +
{toast.sub}
+ )} +
+ ), + action: ({ hide }) => ( +
+ + {toast.reconnect.length > 0 && ( + + )} +
+ ), + }); + return true; + }, + [run, reconnect, showToast], + ); + + return { apply, trusting }; +} + +/** + * The addresses passed through after clients rejected the certificate, kept current as the + * engine adds and forgets them. Null until read. + */ +export function usePassthrough(): string[] | null { + const queryClient = useQueryClient(); + const onChanged = useCallback( + (payload: PassthroughResponse) => + queryClient.setQueryData(PASSTHROUGH_KEY, payload), + [queryClient], + ); + useRpcEvent("passthrough_changed", onChanged); + return useRpcQuery("list_passthrough", {}).data?.addresses ?? null; +} diff --git a/apps/yaak-proxy/hooks/useDecryptList.ts b/apps/yaak-proxy/hooks/useDecryptList.ts index c8d67bfbe..04d6f3058 100644 --- a/apps/yaak-proxy/hooks/useDecryptList.ts +++ b/apps/yaak-proxy/hooks/useDecryptList.ts @@ -5,9 +5,7 @@ import type { DecryptListEntry, } from "@yaakapp-internal/proxy-lib"; import { useCallback } from "react"; -import { useShowToast } from "../lib/toast"; import { useRpcEvent } from "./useRpcEvent"; -import { useRpcMutation } from "./useRpcMutation"; import { useRpcQuery } from "./useRpcQuery"; const KEY = ["get_decrypt_list", {}]; @@ -27,19 +25,3 @@ export function useDecryptList(): DecryptListEntry[] { useRpcEvent("decrypt_list_changed", onChanged); return useRpcQuery("get_decrypt_list", {}).data?.entries ?? EMPTY; } - -/** - * Adds and removes entries, `edit_decrypt_list` applying them to the list as it is when they - * land, so two changes made in quick succession both stick. Everything that reads the list - * then reads it again. A failure, such as an entry that does not parse, is a toast. - */ -export function useEditDecryptList() { - const queryClient = useQueryClient(); - const showToast = useShowToast(); - return useRpcMutation("edit_decrypt_list", { - // Read again rather than taken from the answer, which can land after a newer change's - // event and would put the older list back. - onSuccess: () => queryClient.invalidateQueries({ queryKey: KEY }), - onError: (e) => showToast({ id: "decrypt-list", color: "danger", message: e.message }), - }); -} diff --git a/apps/yaak-proxy/lib/body.test.ts b/apps/yaak-proxy/lib/body.test.ts index b253dd4f5..548775161 100644 --- a/apps/yaak-proxy/lib/body.test.ts +++ b/apps/yaak-proxy/lib/body.test.ts @@ -2,6 +2,7 @@ import { QueryClient, QueryObserver } from "@tanstack/react-query"; import type { Flow } from "@yaakapp-internal/proxy-lib"; import { describe, expect, test, vi } from "vite-plus/test"; import { + bodyTypeBadge, bodyAsText, bodyFileName, bodyImageType, @@ -243,3 +244,27 @@ describe("a body that isn't there", () => { expect(emptyBodyText("request", body.note)).toBe("No request body"); }); }); + +describe("bodyTypeBadge", () => { + test("names a body by its type, as short as the Type column", () => { + expect(bodyTypeBadge("application/json; charset=utf-8", 10)).toBe("JSON"); + expect(bodyTypeBadge("application/problem+json", 10)).toBe("JSON"); + expect(bodyTypeBadge("text/xml", 10)).toBe("XML"); + expect(bodyTypeBadge("text/html", 10)).toBe("HTML"); + expect(bodyTypeBadge("text/csv", 10)).toBe("CSV"); + expect(bodyTypeBadge("application/x-www-form-urlencoded", 10)).toBe("Form"); + expect(bodyTypeBadge("multipart/form-data; boundary=x", 10)).toBe("Multipart"); + expect(bodyTypeBadge("image/png", 10)).toBe("Image"); + expect(bodyTypeBadge("image/svg+xml", 10)).toBe("SVG"); + expect(bodyTypeBadge("text/plain", 10)).toBe("Text"); + expect(bodyTypeBadge("application/grpc", 10)).toBe("Protobuf"); + expect(bodyTypeBadge("application/octet-stream", 10)).toBe("Binary"); + expect(bodyTypeBadge(null, 10)).toBe("Binary"); + }); + + test("GraphQL when the request is an operation, and nothing without a body", () => { + expect(bodyTypeBadge("application/json", 10, true)).toBe("GraphQL"); + expect(bodyTypeBadge("application/json", 0)).toBeNull(); + expect(bodyTypeBadge(null, 0, true)).toBeNull(); + }); +}); diff --git a/apps/yaak-proxy/lib/body.ts b/apps/yaak-proxy/lib/body.ts index 3fdb468bf..8bc3b50d7 100644 --- a/apps/yaak-proxy/lib/body.ts +++ b/apps/yaak-proxy/lib/body.ts @@ -208,6 +208,45 @@ export function shortContentType(contentType: string | null): string | null { return suffix.replace(/^x-/, ""); } +/** + * The short name a body tab's badge gives a body's type, as the Type column and the Pretty and + * Raw switch read it: JSON, XML, HTML, CSV, Form, Multipart, GraphQL, Image, Binary and so on. + * Null when there is no body. + */ +export function bodyTypeBadge( + contentType: string | null, + size: number, + graphql = false, +): string | null { + if (size <= 0) return null; + if (graphql) return "GraphQL"; + const essence = mimeEssence(contentType); + if (essence == null) return "Binary"; + const [type = "", subtype = ""] = essence.split("/"); + const suffix = subtype.split("+").pop() ?? subtype; + if (type === "multipart") return "Multipart"; + if (essence === "application/x-www-form-urlencoded") return "Form"; + if (essence === "application/graphql" || suffix === "graphql") return "GraphQL"; + if (essence === "image/svg+xml") return "SVG"; + if (type === "image") return "Image"; + if (type === "audio") return "Audio"; + if (type === "video") return "Video"; + if (type === "font") return "Font"; + if (suffix === "json" || suffix === "x-ndjson" || suffix === "ndjson") return "JSON"; + if (suffix === "xml") return "XML"; + if (suffix === "html" || suffix === "xhtml") return "HTML"; + if (suffix === "csv") return "CSV"; + if (suffix === "tab-separated-values") return "TSV"; + if (suffix === "javascript" || suffix === "ecmascript") return "JS"; + if (suffix === "css") return "CSS"; + if (suffix === "markdown") return "Markdown"; + if (suffix === "event-stream") return "SSE"; + if (suffix === "pdf") return "PDF"; + if (/grpc|protobuf|proto/.test(suffix)) return "Protobuf"; + if (type === "text") return "Text"; + return "Binary"; +} + /** Extensions for media types whose subtype isn't one */ const EXTENSIONS: Record = { "application/javascript": "js", diff --git a/apps/yaak-proxy/lib/decrypt.test.ts b/apps/yaak-proxy/lib/decrypt.test.ts index b14d48ac5..02a453adb 100644 --- a/apps/yaak-proxy/lib/decrypt.test.ts +++ b/apps/yaak-proxy/lib/decrypt.test.ts @@ -11,8 +11,12 @@ import { decryptMenuAction, decrypts, decryptsEverything, + editDecrypts, entryLabel, + entrySubject, everythingEdit, + inverseEdit, + listSummary, isDecrypted, listEdit, normalizeEntry, @@ -170,15 +174,31 @@ describe("the sidebar menu", () => { const a = { kind: "host", host: "a.com" } as const; const b = { kind: "host", host: "b.com" } as const; - test("offers Decrypt unless every selected row is decrypted", () => { + test("offers Decrypt unless every selected row is decrypted, naming what it changes", () => { expect(decryptMenuAction(user("host:a.com"), [a])).toEqual({ - label: "Stop Decrypting", + label: "Stop Decrypting a.com", on: false, + subject: "a.com", + }); + expect(decryptMenuAction(user("host:a.com"), [a, b])).toEqual({ + label: "Decrypt 2 Hosts", + on: true, + subject: "2 hosts", }); - expect(decryptMenuAction(user("host:a.com"), [a, b])).toEqual({ label: "Decrypt", on: true }); expect(decryptMenuAction([], [])).toBeNull(); }); + test("names sites, apps and mixed rows", () => { + const site = { kind: "site", site: "google.com" } as const; + const dia = { kind: "app", name: "Dia", bundleId: null } as const; + expect(decryptMenuAction([], [site])?.label).toBe("Decrypt *.google.com"); + expect(decryptMenuAction([], [dia])?.label).toBe("Decrypt Dia"); + expect(decryptMenuAction([], [site, { kind: "site", site: "x.com" }])?.label).toBe( + "Decrypt 2 Sites", + ); + expect(decryptMenuAction([], [dia, a, site])?.label).toBe("Decrypt 3 Selected"); + }); + test("applies to every selected row, leaving those already so", () => { const next = setDecrypted(user("host:a.com"), [a, b], true).map((e) => e.entry); expect(next).toEqual(["host:a.com", "host:b.com"]); @@ -225,8 +245,8 @@ describe("the Not decrypted detail's buttons", () => { }); }); -describe("the first-run step", () => { - test("Everything adds *, and Pick as they appear takes it out", () => { +describe("Decrypt Everything", () => { + test("adds *, and the switch off takes it out", () => { expect(decryptsEverything(BUILT_INS)).toBe(false); expect(decryptsEverything([...user("*"), ...BUILT_INS])).toBe(true); expect(everythingEdit(true)).toEqual({ add: ["*"], remove: [] }); @@ -303,3 +323,26 @@ describe("the list's autocomplete", () => { }); }); }); + +describe("what a change says", () => { + test("the pill counts the person's own positive entries", () => { + expect(listSummary([...user("app:Dia", "app:Slack", "host:*.google.com"), ...BUILT_INS])).toBe( + "2 apps, 1 host", + ); + expect(listSummary([...user("*", "!host:a.com"), ...BUILT_INS])).toBeNull(); + }); + + test("names an entry's subject", () => { + expect(entrySubject("host:www.google.com")).toBe("www.google.com"); + expect(entrySubject("host:*.google.com")).toBe("*.google.com"); + expect(entrySubject("app:Dia")).toBe("Dia"); + expect(entrySubject("*")).toBe("Everything"); + }); + + test("an edit can decrypt when it adds a positive entry or drops an exclusion", () => { + expect(editDecrypts({ add: ["host:a.com"], remove: [] })).toBe(true); + expect(editDecrypts({ add: [], remove: ["!host:a.com"] })).toBe(true); + expect(editDecrypts({ add: ["!app:Dia"], remove: ["host:a.com"] })).toBe(false); + expect(inverseEdit({ add: ["a"], remove: ["b"] })).toEqual({ add: ["b"], remove: ["a"] }); + }); +}); diff --git a/apps/yaak-proxy/lib/decrypt.ts b/apps/yaak-proxy/lib/decrypt.ts index d8a554222..6d0afc237 100644 --- a/apps/yaak-proxy/lib/decrypt.ts +++ b/apps/yaak-proxy/lib/decrypt.ts @@ -163,6 +163,12 @@ function matches(e: ParsedEntry, q: DecryptQuery): boolean { } } +/** Whether one entry, as stored, names `q`, whichever way it points */ +export function entryMatches(entry: string, q: DecryptQuery): boolean { + const parsed = parseEntry(entry); + return parsed != null && matches(parsed, q); +} + function specificity(e: ParsedEntry): number { if (e.kind === "everything") return 0; if (e.kind === "app") return Number.POSITIVE_INFINITY; @@ -193,6 +199,52 @@ export function decryptsAnything(list: DecryptListEntry[]): boolean { return list.some((e) => !e.entry.trim().startsWith("!")); } +/** What a list entry is called in a button: its host or pattern, its app, or Everything */ +export function entrySubject(entry: string): string { + const parsed = parseEntry(entry); + if (parsed == null) return entry; + if (parsed.kind === "everything") return "Everything"; + if (parsed.kind === "app") return parsed.pattern; + return formatEntry({ ...parsed, negated: false }).slice("host:".length); +} + +/** + * The person's own positive entries, counted for the pill: `2 apps, 1 host`, sites counted as + * hosts, `*` left out as the switch beside it shows it. Null when there are none. + */ +export function listSummary(list: DecryptListEntry[]): string | null { + let apps = 0; + let hosts = 0; + for (const e of list) { + if (e.builtIn) continue; + const parsed = parseEntry(e.entry); + if (parsed == null || parsed.negated) continue; + if (parsed.kind === "app") apps++; + if (parsed.kind === "host") hosts++; + } + const parts = [ + apps > 0 ? `${apps} ${apps === 1 ? "app" : "apps"}` : null, + hosts > 0 ? `${hosts} ${hosts === 1 ? "host" : "hosts"}` : null, + ].filter((p) => p != null); + return parts.length === 0 ? null : parts.join(", "); +} + +/** Whether an edit can make something decrypt: it adds a positive entry or drops an exclusion */ +export function editDecrypts(edit: { add: string[]; remove: string[] }): boolean { + return ( + edit.add.some((e) => !e.trim().startsWith("!")) || + edit.remove.some((e) => e.trim().startsWith("!")) + ); +} + +/** The edit that puts back what `edit` changed, for Undo */ +export function inverseEdit(edit: { add: string[]; remove: string[] }): { + add: string[]; + remove: string[]; +} { + return { add: edit.remove, remove: edit.add }; +} + /** What a sidebar row or the detail's buttons decrypt: a host, a site, or an app */ export type DecryptSubject = | { kind: "host"; host: string } @@ -335,10 +387,44 @@ export function setDecrypted( export function decryptMenuAction( list: DecryptListEntry[], subjects: DecryptSubject[], -): { label: "Decrypt" | "Stop Decrypting"; on: boolean } | null { +): { label: string; on: boolean; subject: string } | null { if (subjects.length === 0) return null; const on = !subjects.every((s) => isDecrypted(list, s)); - return { label: on ? "Decrypt" : "Stop Decrypting", on }; + const subject = subjectsName(subjects); + return { + label: `${on ? "Decrypt" : "Stop Decrypting"} ${subject.title}`, + on, + subject: subject.text, + }; +} + +/** What a subject is called: its host, `*.site`, or its app */ +export function subjectName(subject: DecryptSubject): string { + switch (subject.kind) { + case "host": + return subject.host; + case "site": + return `*.${subject.site}`; + case "app": + return subject.name; + } +} + +/** + * Several subjects named together: one by its name, several of a kind counted, `3 Sites` in a + * menu and `3 sites` in a toast, and mixed kinds as `3 Selected` + */ +function subjectsName(subjects: DecryptSubject[]): { title: string; text: string } { + const [first] = subjects; + if (subjects.length === 1 && first != null) { + const name = subjectName(first); + return { title: name, text: name }; + } + const kinds = new Set(subjects.map((s) => s.kind)); + const n = subjects.length; + if (kinds.size > 1) return { title: `${n} Selected`, text: `${n} selected` }; + const noun = first?.kind === "app" ? "Apps" : first?.kind === "site" ? "Sites" : "Hosts"; + return { title: `${n} ${noun}`, text: `${n} ${noun.toLowerCase()}` }; } function builtIns(list: DecryptListEntry[]): DecryptListEntry[] { diff --git a/apps/yaak-proxy/lib/decryptChoice.test.ts b/apps/yaak-proxy/lib/decryptChoice.test.ts new file mode 100644 index 000000000..217c40958 --- /dev/null +++ b/apps/yaak-proxy/lib/decryptChoice.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test, vi } from "vite-plus/test"; +import { applyChoice, type ChoiceDeps, choiceToast } from "./decryptChoice"; + +function deps(over: Partial = {}) { + const calls: string[] = []; + const d: ChoiceDeps = { + canTrust: true, + readTrusted: vi.fn(async () => { + calls.push("ca_status"); + return false; + }), + trust: vi.fn(async () => { + calls.push("ca_trust"); + return { cancelled: false, trusted: true }; + }), + save: vi.fn(async () => { + calls.push("edit_decrypt_list"); + return { stillEncrypted: 2 }; + }), + ...over, + }; + return { d, calls }; +} + +const decrypt = { add: ["host:www.google.com"], remove: [] }; + +describe("trust at the first Decrypt", () => { + test("trusts first while untrusted, then writes the change", async () => { + const { d, calls } = deps(); + expect(await applyChoice(d, decrypt)).toEqual({ status: "applied", stillEncrypted: 2 }); + expect(calls).toEqual(["ca_status", "ca_trust", "edit_decrypt_list"]); + }); + + test("a cancelled prompt writes nothing", async () => { + const { d, calls } = deps({ + trust: async () => { + calls.push("ca_trust"); + return { cancelled: true, trusted: false }; + }, + }); + expect(await applyChoice(d, decrypt)).toEqual({ status: "cancelled" }); + expect(calls).toEqual(["ca_status", "ca_trust"]); + }); + + test("a prompt answered without trust writes nothing either", async () => { + const { d } = deps({ trust: async () => ({ cancelled: false, trusted: false }) }); + expect(await applyChoice(d, decrypt)).toEqual({ status: "untrusted" }); + expect(d.save).not.toHaveBeenCalled(); + }); + + test("a failed prompt fails the choice, nothing written", async () => { + const { d } = deps({ + trust: async () => { + throw new Error("Not supported"); + }, + }); + await expect(applyChoice(d, decrypt)).rejects.toThrow("Not supported"); + expect(d.save).not.toHaveBeenCalled(); + }); + + test("asks nothing once trusted", async () => { + const { d, calls } = deps({ + readTrusted: async () => { + calls.push("ca_status"); + return true; + }, + }); + await applyChoice(d, decrypt); + expect(calls).toEqual(["ca_status", "edit_decrypt_list"]); + }); + + test("stopping never asks, and dropping an exclusion does", async () => { + const stop = deps(); + await applyChoice(stop.d, { add: ["!app:Dia"], remove: ["host:www.google.com"] }); + expect(stop.calls).toEqual(["edit_decrypt_list"]); + + const unexclude = deps(); + await applyChoice(unexclude.d, { add: [], remove: ["!host:*.chase.com"] }); + expect(unexclude.calls).toEqual(["ca_status", "ca_trust", "edit_decrypt_list"]); + }); + + test("where the app can't trust the certificate itself, the entry is written at once", async () => { + const { d, calls } = deps({ canTrust: false }); + await applyChoice(d, decrypt); + expect(calls).toEqual(["edit_decrypt_list"]); + }); +}); + +describe("the toast after a choice", () => { + const choice = { edit: decrypt, message: "Decrypting www.google.com" }; + + test("says what was chosen, and nothing to reconnect when nothing open stays encrypted", () => { + expect(choiceToast(choice, 0)).toEqual({ + message: "Decrypting www.google.com", + sub: null, + reconnect: [], + }); + expect( + choiceToast( + { edit: { add: ["*"], remove: [] }, message: "Decrypting everything", sub: "Except" }, + 0, + ).sub, + ).toBe("Except"); + }); + + test("offers Reconnect for the entry when open connections stay encrypted", () => { + expect(choiceToast(choice, 3)).toEqual({ + message: "Decrypting www.google.com", + sub: "3 open connections stay encrypted", + reconnect: ["host:www.google.com"], + }); + expect(choiceToast(choice, 1).sub).toBe("1 open connection stays encrypted"); + }); + + test("Reconnect for an exclusion taken off names the exclusion, as the engine reads it", () => { + const stopExcluding = { edit: { add: [], remove: ["!host:*.chase.com"] }, message: "x" }; + expect(choiceToast(stopExcluding, 1).reconnect).toEqual(["!host:*.chase.com"]); + }); +}); diff --git a/apps/yaak-proxy/lib/decryptChoice.ts b/apps/yaak-proxy/lib/decryptChoice.ts new file mode 100644 index 000000000..c913f4e08 --- /dev/null +++ b/apps/yaak-proxy/lib/decryptChoice.ts @@ -0,0 +1,79 @@ +import { editDecrypts } from "./decrypt"; + +/** A change to the decrypt list, as `edit_decrypt_list` takes it */ +export interface ListEdit { + add: string[]; + remove: string[]; +} + +/** A Decrypt or Stop Decrypting chosen somewhere in the app, with what its toast says */ +export interface DecryptChoice { + edit: ListEdit; + /** "Decrypting www.google.com" */ + message: string; + /** Under it, when nothing more pressing is */ + sub?: string; +} + +/** What applying a choice needs from the app: the certificate's state and the engine */ +export interface ChoiceDeps { + /** The app can trust the certificate itself, as on macOS; elsewhere the entry is written at once */ + canTrust: boolean; + /** Reads whether the certificate is trusted now */ + readTrusted: () => Promise; + /** macOS's password prompt */ + trust: () => Promise<{ cancelled: boolean; trusted: boolean }>; + save: (edit: ListEdit) => Promise<{ stillEncrypted: number }>; +} + +export type ChoiceResult = + | { status: "applied"; stillEncrypted: number } + /** The password prompt was cancelled: nothing changed, and nothing to say */ + | { status: "cancelled" } + /** The prompt was answered but the certificate is still not trusted */ + | { status: "untrusted" }; + +/** + * Trust at the first Decrypt: a change that can make something decrypt first trusts the + * certificate while it isn't, then writes the change only once that succeeded. A change that + * only stops decrypting never asks. + */ +export async function applyChoice(deps: ChoiceDeps, edit: ListEdit): Promise { + if (deps.canTrust && editDecrypts(edit) && !(await deps.readTrusted())) { + const result = await deps.trust(); + if (result.cancelled) return { status: "cancelled" }; + if (!result.trusted) return { status: "untrusted" }; + } + const saved = await deps.save(edit); + return { status: "applied", stillEncrypted: saved.stillEncrypted }; +} + +/** The toast after a choice was applied */ +export interface ChoiceToast { + message: string; + sub: string | null; + /** + * The entries Reconnect closes the open connections of, one `reconnect_tunnels` each: those + * the change added, and exclusions it took off. Empty when nothing open stays encrypted. + */ + reconnect: string[]; +} + +/** + * Says what was chosen, with Undo; when open connections the list now decrypts stay encrypted, + * how many, with Reconnect for them. Nothing reconnects without that click. + */ +export function choiceToast(choice: DecryptChoice, stillEncrypted: number): ChoiceToast { + if (stillEncrypted <= 0) + return { message: choice.message, sub: choice.sub ?? null, reconnect: [] }; + const n = stillEncrypted.toLocaleString(); + const reconnect = [ + ...choice.edit.add.filter((e) => !e.trim().startsWith("!")), + ...choice.edit.remove.filter((e) => e.trim().startsWith("!")), + ]; + return { + message: choice.message, + sub: `${n} open ${stillEncrypted === 1 ? "connection stays" : "connections stay"} encrypted`, + reconnect, + }; +} diff --git a/apps/yaak-proxy/lib/defaultTab.test.ts b/apps/yaak-proxy/lib/defaultTab.test.ts index b0773c294..e70bd4526 100644 --- a/apps/yaak-proxy/lib/defaultTab.test.ts +++ b/apps/yaak-proxy/lib/defaultTab.test.ts @@ -5,11 +5,11 @@ import { learnTab, REQUEST_TAB_BODY, REQUEST_TAB_HEADERS, + REQUEST_TAB_INFO, RESPONSE_TAB_BODY, + RESPONSE_TAB_COOKIES, RESPONSE_TAB_HEADERS, - RESPONSE_TAB_INFO, RESPONSE_TAB_MESSAGES, - RESPONSE_TAB_TIMELINE, type TabMemory, } from "./defaultTab"; @@ -33,13 +33,7 @@ function memory(): TabMemory & { items: Map } { }; } -const RESPONSE_TABS = [ - RESPONSE_TAB_BODY, - RESPONSE_TAB_HEADERS, - "cookies", - RESPONSE_TAB_TIMELINE, - RESPONSE_TAB_INFO, -]; +const RESPONSE_TABS = [RESPONSE_TAB_BODY, RESPONSE_TAB_HEADERS, RESPONSE_TAB_COOKIES]; describe("flowShape", () => { test("names what the flow has", () => { @@ -59,8 +53,8 @@ describe("flowShape", () => { describe("defaultTabFor", () => { test("follows the table", () => { const m = memory(); - // A response body - expect(defaultTabFor("request", ok, [], m)).toBe(REQUEST_TAB_HEADERS); + // A response body, the request's Info, as there is no request body + expect(defaultTabFor("request", ok, [], m)).toBe(REQUEST_TAB_INFO); expect(defaultTabFor("request", { ...ok, requestSize: 9 }, [], m)).toBe(REQUEST_TAB_BODY); expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_BODY); // A request body, no response body @@ -69,27 +63,28 @@ describe("defaultTabFor", () => { expect(defaultTabFor("response", post, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_HEADERS); // Neither const empty = { ...ok, responseSize: 0 }; - expect(defaultTabFor("request", empty, [], m)).toBe(REQUEST_TAB_HEADERS); + expect(defaultTabFor("request", empty, [], m)).toBe(REQUEST_TAB_INFO); expect(defaultTabFor("response", empty, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_HEADERS); // A stream const ws = { ...ok, streamKind: "websocket" as const }; expect(defaultTabFor("response", ws, [...RESPONSE_TABS, RESPONSE_TAB_MESSAGES], m)).toBe( RESPONSE_TAB_MESSAGES, ); - // No response at all + // No response at all: its timeline is in the request's Info const failed = { ...ok, status: null, error: "refused", responseSize: 0 }; - expect(defaultTabFor("response", failed, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_TIMELINE); + expect(defaultTabFor("response", failed, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_HEADERS); + expect(defaultTabFor("request", failed, [], m)).toBe(REQUEST_TAB_INFO); expect(defaultTabFor("request", { ...failed, requestSize: 4 }, [], m)).toBe(REQUEST_TAB_BODY); }); test("a tab chosen by hand becomes the default for the same shape and pane", () => { const m = memory(); - learnTab("response", ok, RESPONSE_TAB_INFO, m); - expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_INFO); + learnTab("response", ok, RESPONSE_TAB_COOKIES, m); + expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_COOKIES); // Not for other shapes, nor the other pane const post = { ...ok, requestSize: 30, responseSize: 0 }; expect(defaultTabFor("response", post, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_HEADERS); - expect(defaultTabFor("request", ok, ["body", "headers", "info"], m)).toBe(REQUEST_TAB_HEADERS); + expect(defaultTabFor("request", ok, ["info", "body", "headers"], m)).toBe(REQUEST_TAB_INFO); }); test("a learned tab the flow doesn't have falls back to the table", () => { @@ -100,6 +95,15 @@ describe("defaultTabFor", () => { expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_BODY); }); + test("the request half opens on Info unless it has a body, and learns like the response", () => { + const m = memory(); + const tabs = [REQUEST_TAB_INFO, REQUEST_TAB_BODY, REQUEST_TAB_HEADERS]; + expect(defaultTabFor("request", ok, tabs, m)).toBe(REQUEST_TAB_INFO); + expect(defaultTabFor("request", { ...ok, requestSize: 5 }, tabs, m)).toBe(REQUEST_TAB_BODY); + learnTab("request", ok, REQUEST_TAB_HEADERS, m); + expect(defaultTabFor("request", ok, tabs, m)).toBe(REQUEST_TAB_HEADERS); + }); + test("works without storage", () => { expect(defaultTabFor("response", ok, RESPONSE_TABS, null)).toBe(RESPONSE_TAB_BODY); }); diff --git a/apps/yaak-proxy/lib/defaultTab.ts b/apps/yaak-proxy/lib/defaultTab.ts index 14c02d83f..0f899c82e 100644 --- a/apps/yaak-proxy/lib/defaultTab.ts +++ b/apps/yaak-proxy/lib/defaultTab.ts @@ -1,6 +1,7 @@ import type { Flow } from "@yaakapp-internal/proxy-lib"; -/** The request half's tabs, named as the shared request pane names them */ +/** The request half's tabs, named as the shared request pane names them, Info first */ +export const REQUEST_TAB_INFO = "info"; export const REQUEST_TAB_BODY = "body"; export const REQUEST_TAB_PARAMS = "params"; export const REQUEST_TAB_HEADERS = "headers"; @@ -11,8 +12,6 @@ export const REQUEST_TAB_COOKIES = "cookies"; export const RESPONSE_TAB_BODY = "body"; export const RESPONSE_TAB_HEADERS = "headers"; export const RESPONSE_TAB_COOKIES = "cookies"; -export const RESPONSE_TAB_TIMELINE = "timeline"; -export const RESPONSE_TAB_INFO = "info"; export const RESPONSE_TAB_MESSAGES = "messages"; export type FlowPane = "request" | "response"; @@ -33,10 +32,13 @@ export function flowShape( return "neither"; } -/** The tab a half opens on for a shape, before anything was learned */ +/** + * The tab a half opens on for a shape, before anything was learned: the request half on its + * body when it has one, else on Info; the response half on what it has + */ export function shapeDefaultTab(pane: FlowPane, shape: FlowShape, hasRequestBody: boolean): string { if (pane === "request") { - return hasRequestBody ? REQUEST_TAB_BODY : REQUEST_TAB_HEADERS; + return hasRequestBody ? REQUEST_TAB_BODY : REQUEST_TAB_INFO; } switch (shape) { case "response_body": @@ -44,7 +46,6 @@ export function shapeDefaultTab(pane: FlowPane, shape: FlowShape, hasRequestBody case "stream": return RESPONSE_TAB_MESSAGES; case "no_response": - return RESPONSE_TAB_TIMELINE; case "request_body": case "neither": return RESPONSE_TAB_HEADERS; diff --git a/apps/yaak-proxy/lib/encryptedCard.test.ts b/apps/yaak-proxy/lib/encryptedCard.test.ts new file mode 100644 index 000000000..6ee3db6a2 --- /dev/null +++ b/apps/yaak-proxy/lib/encryptedCard.test.ts @@ -0,0 +1,335 @@ +import type { DecryptListEntry } from "@yaakapp-internal/proxy-lib"; +import { describe, expect, test } from "vite-plus/test"; +import { + connectionOf, + type EncryptedCardInput, + encryptedCard, + stopDecryptingConnection, +} from "./encryptedCard"; + +const user = (...entries: string[]): DecryptListEntry[] => + entries.map((entry) => ({ entry, builtIn: false })); +const BUILT_INS: DecryptListEntry[] = [ + { entry: "!host:*.apple.com", builtIn: true, kind: "apple" }, + { entry: "!host:*.1password.com", builtIn: true, kind: "pinned" }, +]; + +const dia = { + clientName: "Dia", + clientBundleId: "company.thebrowser.dia", + clientPath: "/A/Dia.app", +}; +const tunnel: EncryptedCardInput["flow"] = { + url: "www.google.com:443", + ...dia, + tunnelReason: "not_listed", + tunnelExcludedBy: null, + tunnelExcludedBuiltIn: false, + tlsRejected: null, + elapsedMs: 188, + error: null, +}; + +function card(over: Partial = {}) { + return encryptedCard({ + flow: tunnel, + list: BUILT_INS, + trusted: true, + canTrust: true, + passthrough: [], + ...over, + }); +} + +const summary = (c: ReturnType) => + c.actions.map((a) => [a.label, a.hint, a.edit ?? null]); + +describe("connectionOf", () => { + test("reads a tunnel's authority and a refused handshake's URL alike", () => { + expect( + connectionOf({ url: "www.google.com:443", clientName: null, clientBundleId: null }), + ).toEqual({ + host: "www.google.com", + port: 443, + label: "www.google.com", + address: "www.google.com:443", + app: null, + }); + const c = connectionOf({ url: "https://home.ts.net:8317", ...dia }); + expect(c.label).toBe("home.ts.net:8317"); + expect(c.app).toEqual({ name: "Dia", bundleId: "company.thebrowser.dia" }); + expect( + connectionOf({ url: "[::1]:8443", clientName: null, clientBundleId: null }), + ).toMatchObject({ host: "::1", port: 8443, address: "[::1]:8443", label: "[::1]:8443" }); + }); +}); + +describe("the card of an encrypted connection", () => { + test("nothing decrypted yet: the host, the app and everything, each with what it writes", () => { + const c = card(); + expect(c.why).toBe("Nothing is decrypted yet"); + expect(summary(c)).toEqual([ + [ + "Decrypt www.google.com", + "host:www.google.com", + { add: ["host:www.google.com"], remove: [] }, + ], + ["Decrypt Dia", "app:Dia", { add: ["app:Dia"], remove: [] }], + ["Decrypt Everything", "*", { add: ["*"], remove: [] }], + ]); + expect(c.foot).toEqual(["New connections only"]); + expect(c.fact).toBeNull(); + }); + + test("asks for the password once while the certificate isn't trusted, where it can be", () => { + expect(card({ trusted: false }).foot).toEqual([ + "New connections only", + "Asks for your password once", + ]); + expect(card({ trusted: false, canTrust: false }).foot).toEqual(["New connections only"]); + }); + + test("something else listed: not on the list, with the same choices", () => { + const c = card({ list: [...user("host:api.stripe.com"), ...BUILT_INS] }); + expect(c.why).toBe("Not on the decrypt list"); + expect(c.actions.map((a) => a.label)).toEqual([ + "Decrypt www.google.com", + "Decrypt Dia", + "Decrypt Everything", + ]); + }); + + test("no app button for a connection no app is named for", () => { + const c = card({ + flow: { ...tunnel, clientName: null, clientBundleId: null, clientPath: null }, + }); + expect(c.actions.map((a) => a.label)).toEqual(["Decrypt www.google.com", "Decrypt Everything"]); + }); + + test("a port off 443 is part of the host it writes", () => { + const c = card({ flow: { ...tunnel, url: "home.ts.net:8317" } }); + expect(c.actions[0]?.hint).toBe("host:home.ts.net:8317"); + expect(c.actions[0]?.label).toBe("Decrypt home.ts.net:8317"); + }); + + test("Decrypt Everything is offered only where * would decrypt it", () => { + const flow = { ...tunnel, url: "my.1password.com:443", clientName: "1Password" }; + expect(card({ flow }).actions.map((a) => a.label)).not.toContain("Decrypt Everything"); + }); + + test("excluded by default: the host alone, dashed, and who may refuse it", () => { + const pinned = card({ + flow: { + ...tunnel, + url: "my.1password.com:443", + clientName: "1Password", + clientBundleId: "com.1password", + tunnelReason: "excluded", + tunnelExcludedBy: "!host:*.1password.com", + tunnelExcludedBuiltIn: true, + }, + }); + expect([pinned.why, pinned.whySub]).toEqual(["Excluded by default", "pins its certificate"]); + expect(summary(pinned)).toEqual([ + [ + "Decrypt my.1password.com", + "host:my.1password.com", + { add: ["host:my.1password.com"], remove: [] }, + ], + ]); + expect(pinned.actions[0]?.dashed).toBe(true); + expect(pinned.foot).toEqual(["1Password may refuse it"]); + + const apple = card({ + flow: { + ...tunnel, + url: "gateway.icloud.apple.com:443", + clientName: null, + clientBundleId: null, + tunnelReason: "excluded", + tunnelExcludedBy: "!host:*.apple.com", + tunnelExcludedBuiltIn: true, + }, + }); + expect(apple.whySub).toBe("Apple service"); + expect(apple.foot).toEqual(["macOS may refuse it"]); + }); + + test("excluded by the person: names it, and Stop Excluding when that alone decrypts it", () => { + const list = [...user("*", "!host:*.google.com"), ...BUILT_INS]; + const c = card({ + list, + flow: { ...tunnel, tunnelReason: "excluded", tunnelExcludedBy: "!host:*.google.com" }, + }); + expect([c.why, c.whyEntry]).toEqual(["Excluded by", "!host:*.google.com"]); + // Nothing added wins over the person's own exclusion, so taking it off is the way in + expect(summary(c)).toEqual([ + [ + "Stop Excluding *.google.com", + "!host:*.google.com", + { add: [], remove: ["!host:*.google.com"] }, + ], + ]); + // Their exact exclusion of the host is one Decrypt takes off itself + const exact = card({ list: [...user("*", "!host:www.google.com"), ...BUILT_INS] }); + expect(summary(exact)[0]).toEqual([ + "Decrypt www.google.com", + "host:www.google.com", + { add: [], remove: ["!host:www.google.com"] }, + ]); + + // Every way in blocked but taking the exclusions off + const both = [...user("*", "!host:*.google.com", "!app:Dia"), ...BUILT_INS]; + const blocked = card({ list: both }); + expect(summary(blocked)).toEqual([ + [ + "Stop Excluding 2 Entries", + "!host:*.google.com, !app:Dia", + { add: [], remove: ["!host:*.google.com", "!app:Dia"] }, + ], + ]); + expect(blocked.actions[0]?.removes).toBe(true); + }); + + test("once the list decrypts it: opened before, the opposite, and Reconnect while open", () => { + const list = [...user("host:www.google.com"), ...BUILT_INS]; + const closed = card({ list }); + expect(closed.why).toBe("Not on the decrypt list"); + expect(closed.fact).toBe("Opened before www.google.com was decrypted"); + expect(summary(closed)).toEqual([ + [ + "Stop Decrypting www.google.com", + "host:www.google.com", + { add: [], remove: ["host:www.google.com"] }, + ], + ]); + expect(closed.foot).toEqual([]); + + const open = card({ list, flow: { ...tunnel, elapsedMs: null } }); + expect(open.actions.map((a) => a.kind)).toEqual(["reconnect", "stop"]); + + const everything = card({ list: [...user("*"), ...BUILT_INS] }); + expect(everything.fact).toBe("Opened before everything was decrypted"); + expect(everything.actions[0]?.label).toBe("Stop Decrypting Everything"); + }); +}); + +describe("the card of a refused handshake", () => { + const refused: EncryptedCardInput["flow"] = { + ...tunnel, + url: "https://sync.acme.dev", + clientName: "Acme", + clientBundleId: "dev.acme", + clientPath: "/A/Acme.app", + tunnelReason: null, + tlsRejected: "bad_certificate", + elapsedMs: 6, + }; + const listed = [...user("*"), ...BUILT_INS]; + + test("an app: likely pinned, Stop Decrypting first, Try Again while passed through", () => { + const c = card({ flow: refused, list: listed, passthrough: ["sync.acme.dev:443"] }); + expect(c.rejected).toBe(true); + expect([c.why, c.whySub]).toEqual(["Acme rejected the certificate", "likely pinned"]); + expect(summary(c)).toEqual([ + ["Stop Decrypting Acme", "!app:Acme", { add: ["!app:Acme"], remove: [] }], + ["Try Again", "next connection", null], + ]); + expect(c.actions[0]?.primary).toBe(true); + expect(c.foot).toEqual(["Passed through until relaunch"]); + }); + + test("once no longer passed through, Try Again goes and the foot says so", () => { + const c = card({ flow: refused, list: listed, passthrough: [] }); + expect(c.actions.map((a) => a.kind)).toEqual(["stop"]); + expect(c.foot).toEqual(["No longer passed through"]); + }); + + test("a program: its own trust store, Copy Environment first", () => { + const python = { + ...refused, + url: "https://pypi.org", + clientName: "python3", + clientBundleId: null, + clientPath: "/opt/homebrew/bin/python3", + }; + const c = card({ flow: python, list: listed, passthrough: ["pypi.org:443"] }); + expect([c.why, c.whySub]).toEqual([ + "python3 rejected the certificate", + "uses its own trust store", + ]); + expect(c.actions.map((a) => [a.kind, a.label])).toEqual([ + ["copy_environment", "Copy Environment"], + ["stop", "Stop Decrypting python3"], + ["try_again", "Try Again"], + ]); + expect(c.foot).toEqual(["Then rerun python3"]); + }); + + test("an unknown client: the host", () => { + const c = card({ + flow: { ...refused, clientName: null, clientBundleId: null, clientPath: null }, + list: listed, + }); + expect(c.why).toBe("A client rejected the certificate"); + expect(summary(c)[0]).toEqual([ + "Stop Decrypting sync.acme.dev", + "!host:sync.acme.dev", + { add: ["!host:sync.acme.dev"], remove: [] }, + ]); + }); + + test("an untrusted certificate explains it: Trust Certificate first", () => { + const c = card({ + flow: refused, + list: listed, + trusted: false, + passthrough: ["sync.acme.dev:443"], + }); + expect(c.why).toBe("Certificate not trusted"); + expect(c.actions.map((a) => a.kind)).toEqual(["trust", "stop"]); + expect(c.foot).toEqual(["Passed through until trusted"]); + }); + + test("a tunnel after a refusal names the client that refused", () => { + const after = { + ...tunnel, + url: "sync.acme.dev:443", + tunnelReason: "rejected" as const, + }; + const c = card({ + flow: after, + list: listed, + rejectedBy: { clientName: "Acme", clientBundleId: "dev.acme", clientPath: null }, + }); + expect(c.why).toBe("Acme rejected the certificate for this host"); + // Stop Decrypting acts on this connection, so on Dia's own + expect(c.actions[0]?.hint).toBe("!app:Dia"); + }); +}); + +describe("Stop Decrypting a refused connection", () => { + const c = connectionOf({ url: "sync.acme.dev:443", clientName: "Acme", clientBundleId: null }); + + test("takes off the entry naming exactly this host when that alone stops it", () => { + const action = stopDecryptingConnection(user("host:sync.acme.dev"), c); + expect(action?.edit).toEqual({ add: [], remove: ["host:sync.acme.dev"] }); + expect(action?.label).toBe("Stop Decrypting sync.acme.dev"); + }); + + test("else excludes the app, or the host without one, which always wins", () => { + expect(stopDecryptingConnection(user("*"), c)?.edit).toEqual({ + add: ["!app:Acme"], + remove: [], + }); + const anonymous = { ...c, app: null }; + expect(stopDecryptingConnection(user("*", "host:sync.acme.dev"), anonymous)?.edit).toEqual({ + add: ["!host:sync.acme.dev"], + remove: [], + }); + }); + + test("nothing when the list does not decrypt it", () => { + expect(stopDecryptingConnection(BUILT_INS, c)).toBeNull(); + }); +}); diff --git a/apps/yaak-proxy/lib/encryptedCard.ts b/apps/yaak-proxy/lib/encryptedCard.ts new file mode 100644 index 000000000..ff8aaae70 --- /dev/null +++ b/apps/yaak-proxy/lib/encryptedCard.ts @@ -0,0 +1,404 @@ +import type { DecryptListEntry, Flow } from "@yaakapp-internal/proxy-lib"; +import { + type DecryptQuery, + type DecryptSubject, + decrypts, + decryptsAnything, + detailActions, + EVERYTHING, + entryMatches, + entrySubject, + listEdit, + setDecrypted, + subjectEntry, +} from "./decrypt"; +import { hostnameOf } from "./search"; + +/** Where an encrypted connection went, and the app that opened it, as the list judges it */ +export interface Connection { + /** The host alone, an IPv6 address without brackets */ + host: string; + port: number; + /** The host as a button names it: with its port when that is not 443 */ + label: string; + /** `host:port`, an IPv6 host in brackets, as the engine's pass-through set lists it */ + address: string; + app: { name: string; bundleId: string | null } | null; +} + +type ConnectionFlow = Pick; + +/** The connection a tunnel or a refused handshake records: `https://host:port` or `host:port` */ +export function connectionOf(flow: ConnectionFlow): Connection { + const scheme = flow.url.indexOf("://"); + const rest = scheme === -1 ? flow.url : flow.url.slice(scheme + 3); + const authority = (rest.split(/[/?#]/)[0] ?? "").toLowerCase(); + const name = hostnameOf(authority); + const portText = authority.slice(name.length + 1); + const port = /^\d+$/.test(portText) ? Number(portText) : 443; + const host = name.replace(/^\[|\]$/g, ""); + return { + host, + port, + label: port === 443 ? name : `${name}:${port}`, + address: `${name}:${port}`, + app: flow.clientName == null ? null : { name: flow.clientName, bundleId: flow.clientBundleId }, + }; +} + +function queryOf(c: Connection): DecryptQuery { + return { host: c.host, port: c.port, app: c.app }; +} + +/** What a button on the card does */ +export type CardActionKind = + | "decrypt" + | "stop" + | "reconnect" + | "trust" + | "try_again" + | "copy_environment"; + +export interface CardAction { + kind: CardActionKind; + label: string; + /** What it writes, shown beside it: an entry, or a hint like `next connection` */ + hint: string | null; + /** The hint is an entry it takes off the list, so it is struck through */ + removes?: boolean; + /** The list change, for `decrypt` and `stop` */ + edit?: { add: string[]; remove: string[] }; + /** What the toast says was chosen */ + toast?: { message: string; sub?: string }; + /** The suggested action */ + primary?: boolean; + /** The narrowest override of an exclusion that ships for a reason */ + dashed?: boolean; + /** What the icon shows: a host, the app, everything, or the action's own */ + icon: "host" | "app" | "everything" | "lock" | "lock_open" | "refresh" | "shield" | "terminal"; +} + +export interface EncryptedCardModel { + rejected: boolean; + /** Why the connection is encrypted, in one line */ + why: string; + /** Quieter, after it */ + whySub: string | null; + /** The exclusion that kept it out, when the person's own, shown as code after "Excluded by" */ + whyEntry: string | null; + /** A fact about the connection since the list changed: when it opened */ + fact: string | null; + actions: CardAction[]; + /** Small lines under the buttons */ + foot: string[]; +} + +export interface EncryptedCardInput { + flow: Pick< + Flow, + | "url" + | "clientName" + | "clientBundleId" + | "clientPath" + | "tunnelReason" + | "tunnelExcludedBy" + | "tunnelExcludedBuiltIn" + | "tlsRejected" + | "elapsedMs" + | "error" + >; + list: DecryptListEntry[]; + /** The certificate is trusted, null until read */ + trusted: boolean | null; + /** The app can trust the certificate itself, as on macOS */ + canTrust: boolean; + /** The addresses passed through after a rejection, null until read */ + passthrough: string[] | null; + /** For a tunnel passed through after a rejection: the client that refused, when known */ + rejectedBy?: Pick | null; +} + +/** A refused handshake, or a tunnel passed through because of one */ +export function isRejection(flow: Pick): boolean { + return flow.tlsRejected != null || flow.tunnelReason === "rejected"; +} + +/** A tunnel still open: written as it opens, done once it closes */ +export function isOpen(flow: Pick): boolean { + return flow.elapsedMs == null && flow.error == null; +} + +/** + * The card for an HTTPS connection the proxy did not decrypt: why, from what the engine recorded + * as it opened, and the changes to the list that would decrypt the next one like it, judged + * against the list as it is now. Once the list decrypts it, the card says it opened before and + * offers the opposite, and Reconnect while it is open. Nothing on it narrates what happens next. + */ +export function encryptedCard(input: EncryptedCardInput): EncryptedCardModel { + return isRejection(input.flow) ? rejectedCard(input) : tunnelCard(input); +} + +function tunnelCard({ flow, list, trusted, canTrust }: EncryptedCardInput): EncryptedCardModel { + const c = connectionOf(flow); + const q = queryOf(c); + const { why, whySub, whyEntry } = whyLine(flow, list); + const foot = ["New connections only"]; + + if (decrypts(list, q)) { + // Decrypted from the next connection on: the entries that do it, each to take back. + const decrypting = list.filter( + (e) => !e.builtIn && !e.entry.startsWith("!") && entryMatches(e.entry, q), + ); + const actions: CardAction[] = decrypting.map((e) => stopEntry(e.entry)); + if (isOpen(flow)) { + actions.unshift({ kind: "reconnect", label: "Reconnect", hint: null, icon: "refresh" }); + } + const first = decrypting[0]?.entry; + const what = + first == null ? c.label : first === EVERYTHING ? "everything" : entrySubject(first); + return { + rejected: false, + why, + whySub, + whyEntry, + fact: `Opened before ${what} was decrypted`, + actions, + foot: [], + }; + } + + const actions: CardAction[] = []; + const builtInBlock = list.find( + (e) => e.builtIn && e.entry.startsWith("!") && entryMatches(e.entry, q), + ); + const hostSubject: DecryptSubject = { kind: "host", host: c.label }; + const judged = detailActions(list, c.label, c.app); + if (builtInBlock != null) { + // Kept out by default for a reason: only the host itself, the narrowest override. + if (judged.host) { + actions.push({ ...decryptSubject(list, hostSubject, c.label, "host"), dashed: true }); + foot.length = 0; + foot.push( + builtInBlock.kind === "apple" || c.app == null + ? "macOS may refuse it" + : `${c.app.name} may refuse it`, + ); + } + } else { + if (judged.host) actions.push(decryptSubject(list, hostSubject, c.label, "host")); + if (judged.app && c.app != null) { + actions.push(decryptSubject(list, { kind: "app", ...c.app }, c.app.name, "app")); + } + const everything = list.some((e) => e.entry === EVERYTHING); + if (!everything && decrypts([{ entry: EVERYTHING, builtIn: false }, ...list], q)) { + actions.push({ + kind: "decrypt", + label: "Decrypt Everything", + hint: EVERYTHING, + edit: { add: [EVERYTHING], remove: [] }, + toast: { message: "Decrypting everything", sub: "Except Apple services and apps that pin" }, + icon: "everything", + }); + } + if (actions.length === 0) { + // The person's own exclusions keep it out: taking them off, when that alone decrypts it. + const own = list.filter( + (e) => !e.builtIn && e.entry.startsWith("!") && entryMatches(e.entry, q), + ); + const without = list.filter((e) => !own.includes(e)); + if (own.length > 0 && decrypts(without, q)) { + const [only] = own; + const label = + own.length === 1 && only != null + ? `Stop Excluding ${entrySubject(only.entry.slice(1))}` + : `Stop Excluding ${own.length} Entries`; + actions.push({ + kind: "decrypt", + label, + hint: own.map((e) => e.entry).join(", "), + removes: true, + edit: { add: [], remove: own.map((e) => e.entry) }, + toast: { message: label.replace("Stop Excluding", "Stopped excluding") }, + icon: "lock_open", + }); + } + } + } + if (actions.length > 0 && canTrust && trusted === false) foot.push("Asks for your password once"); + return { + rejected: false, + why, + whySub, + whyEntry, + fact: null, + actions, + foot: actions.length === 0 ? [] : foot, + }; +} + +/** The why line, from the reason the engine recorded as the tunnel opened */ +function whyLine( + flow: EncryptedCardInput["flow"], + list: DecryptListEntry[], +): { why: string; whySub: string | null; whyEntry: string | null } { + if (flow.tunnelReason === "excluded" && flow.tunnelExcludedBy != null) { + if (flow.tunnelExcludedBuiltIn) { + const kind = list.find((e) => e.entry === flow.tunnelExcludedBy)?.kind; + const whySub = + kind === "apple" ? "Apple service" : kind === "pinned" ? "pins its certificate" : null; + return { why: "Excluded by default", whySub, whyEntry: null }; + } + return { why: "Excluded by", whySub: null, whyEntry: flow.tunnelExcludedBy }; + } + return { + why: decryptsAnything(list) ? "Not on the decrypt list" : "Nothing is decrypted yet", + whySub: null, + whyEntry: null, + }; +} + +function decryptSubject( + list: DecryptListEntry[], + subject: DecryptSubject, + name: string, + icon: CardAction["icon"], +): CardAction { + return { + kind: "decrypt", + label: `Decrypt ${name}`, + hint: subjectEntry(subject), + edit: listEdit(list, setDecrypted(list, [subject], true)), + toast: { message: `Decrypting ${name}` }, + icon, + }; +} + +function stopEntry(entry: string): CardAction { + const name = entrySubject(entry); + return { + kind: "stop", + label: `Stop Decrypting ${name}`, + hint: entry, + removes: true, + edit: { add: [], remove: [entry] }, + toast: { message: `Stopped decrypting ${entry === EVERYTHING ? "everything" : name}` }, + icon: "lock", + }; +} + +/** + * Stop Decrypting on a refused connection, judged with its host, port and app together: takes + * off the person's entry naming exactly this host when that alone stops it, else excludes the + * app when it is known, else the host, which the list always honours. Null when the list does + * not decrypt it. + */ +export function stopDecryptingConnection( + list: DecryptListEntry[], + connection: Connection, +): CardAction | null { + const q = queryOf(connection); + if (!decrypts(list, q)) return null; + const hostEntry = subjectEntry({ kind: "host", host: connection.label }); + const own = list.find((e) => !e.builtIn && e.entry === hostEntry); + if ( + own != null && + !decrypts( + list.filter((e) => e !== own), + q, + ) + ) { + return { ...stopEntry(own.entry), label: `Stop Decrypting ${connection.label}` }; + } + const name = connection.app?.name ?? connection.label; + const add = connection.app == null ? `!${hostEntry}` : `!app:${connection.app.name}`; + return { + kind: "stop", + label: `Stop Decrypting ${name}`, + hint: add, + edit: { add: [add], remove: [] }, + toast: { message: `Stopped decrypting ${name}` }, + icon: "lock", + }; +} + +/** Who refused: an app with a bundle, a program without one, or a client the app couldn't name */ +type Refuser = + | { kind: "app"; name: string } + | { kind: "program"; name: string } + | { kind: "unknown" }; + +function refuserOf( + client: Pick | null | undefined, +): Refuser { + if (client?.clientName == null) return { kind: "unknown" }; + if (client.clientBundleId != null) return { kind: "app", name: client.clientName }; + return { kind: "program", name: client.clientName }; +} + +function rejectedCard(input: EncryptedCardInput): EncryptedCardModel { + const { flow, list, trusted, canTrust, passthrough } = input; + const c = connectionOf(flow); + // The refused handshake names its own client; a tunnel after it, the one that refused. + const own = flow.tlsRejected != null; + const refuser = refuserOf(own ? flow : input.rejectedBy); + const stop = stopDecryptingConnection(list, c); + const passedThrough = passthrough == null ? null : passthrough.includes(c.address); + const tryAgain: CardAction | null = + passedThrough === false + ? null + : { kind: "try_again", label: "Try Again", hint: "next connection", icon: "refresh" }; + const until = passedThrough === false ? "No longer passed through" : null; + + if (canTrust && trusted === false && decryptsAnything(list)) { + return { + rejected: true, + why: "Certificate not trusted", + whySub: null, + whyEntry: null, + fact: null, + actions: [ + { kind: "trust", label: "Trust Certificate", hint: null, primary: true, icon: "shield" }, + ...(stop == null ? [] : [stop]), + ], + foot: [until ?? "Passed through until trusted"], + }; + } + + const forHost = own ? "" : " for this host"; + if (refuser.kind === "program") { + return { + rejected: true, + why: `${refuser.name} rejected the certificate${forHost}`, + whySub: "uses its own trust store", + whyEntry: null, + fact: null, + actions: [ + { + kind: "copy_environment", + label: "Copy Environment", + hint: "SSL_CERT_FILE, …", + primary: true, + icon: "terminal", + }, + ...(stop == null ? [] : [stop]), + ...(tryAgain == null ? [] : [tryAgain]), + ], + foot: until == null ? [`Then rerun ${refuser.name}`] : [until], + }; + } + return { + rejected: true, + why: + refuser.kind === "app" + ? `${refuser.name} rejected the certificate${forHost}` + : "A client rejected the certificate", + whySub: refuser.kind === "app" ? "likely pinned" : null, + whyEntry: null, + fact: null, + actions: [ + ...(stop == null ? [] : [{ ...stop, primary: true }]), + ...(tryAgain == null ? [] : [tryAgain]), + ], + foot: [until ?? "Passed through until relaunch"], + }; +} diff --git a/apps/yaak-proxy/lib/firstRun.test.ts b/apps/yaak-proxy/lib/firstRun.test.ts index 259cff349..8a28f7d70 100644 --- a/apps/yaak-proxy/lib/firstRun.test.ts +++ b/apps/yaak-proxy/lib/firstRun.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; -import { decryptStep, setupIncomplete } from "./firstRun"; +import { setupIncomplete } from "./firstRun"; describe("setupIncomplete", () => { const done = { systemProxySupported: true, systemProxyEverOn: true }; @@ -21,21 +21,3 @@ describe("setupIncomplete", () => { ); }); }); - -describe("decryptStep", () => { - const builtIns = [{ entry: "!host:*.apple.com", builtIn: true }]; - - test("picks hosts as they appear by default, after the certificate", () => { - expect(decryptStep(builtIns, false)).toEqual({ choice: "pick", state: "todo" }); - expect(decryptStep(builtIns, true)).toEqual({ choice: "pick", state: "next" }); - }); - - test("is done once everything, or a host, is on the list", () => { - const everything = [{ entry: "*", builtIn: false }, ...builtIns]; - expect(decryptStep(everything, true)).toEqual({ choice: "everything", state: "done" }); - const host = [{ entry: "host:api.example.com", builtIn: false }]; - expect(decryptStep(host, true)).toEqual({ choice: "pick", state: "done" }); - // An exclusion alone decrypts nothing. - expect(decryptStep([{ entry: "!host:a.com", builtIn: false }], true).state).toBe("next"); - }); -}); diff --git a/apps/yaak-proxy/lib/firstRun.ts b/apps/yaak-proxy/lib/firstRun.ts index 811b06f36..b3df9ab95 100644 --- a/apps/yaak-proxy/lib/firstRun.ts +++ b/apps/yaak-proxy/lib/firstRun.ts @@ -1,6 +1,3 @@ -import type { DecryptListEntry } from "@yaakapp-internal/proxy-lib"; -import { decryptsEverything } from "./decrypt"; - /** What decides whether setup is done, as the app last read it */ export interface SetupState { /** The system proxy can be set on this platform, or null until read */ @@ -20,21 +17,3 @@ export function setupIncomplete(s: SetupState): boolean { if (s.systemProxySupported == null) return false; return s.systemProxySupported && !s.systemProxyEverOn; } - -/** The first-run card's "Choose what to decrypt" step, as the decrypt list has it */ -export interface DecryptStep { - /** Everything is decrypted, `*` on the list; else hosts are picked as they appear */ - choice: "everything" | "pick"; - state: "todo" | "next" | "done"; -} - -/** - * Picking hosts as they appear is the default and needs nothing done, so the step waits on the - * certificate like the others and is done once a choice is on the list: everything, or any host - * or app picked already. - */ -export function decryptStep(list: DecryptListEntry[], trusted: boolean): DecryptStep { - const choice = decryptsEverything(list) ? "everything" : "pick"; - const picked = list.some((e) => !e.builtIn && !e.entry.startsWith("!")); - return { choice, state: picked ? "done" : trusted ? "next" : "todo" }; -} diff --git a/apps/yaak-proxy/lib/hideSystem.test.ts b/apps/yaak-proxy/lib/hideSystem.test.ts index 1763ce552..8ed6be9c2 100644 --- a/apps/yaak-proxy/lib/hideSystem.test.ts +++ b/apps/yaak-proxy/lib/hideSystem.test.ts @@ -8,8 +8,14 @@ vi.mock("@yaakapp-internal/ui", async () => { return { selectedIdsFamily: () => atom([]) }; }); -const { activeQueryAtom, allTrafficTotalAtom, flowTotalAtom, hideSystemAtom, nonSystemTotalAtom } = - await import("./store"); +const { + activeQueryAtom, + allTrafficTotalAtom, + flowTotalAtom, + hideEncryptedAtom, + hideSystemAtom, + allTrafficShownAtom, +} = await import("./store"); describe("Show in All Traffic, in System's menu", () => { test("shows System's traffic in All traffic by default", () => { @@ -20,13 +26,45 @@ describe("Show in All Traffic, in System's menu", () => { test("leaves System out of All traffic and brings it back at once", () => { const store = createStore(); - store.set(nonSystemTotalAtom, 3); + store.set(allTrafficShownAtom, 3); expect(store.get(allTrafficTotalAtom)).toBe(store.get(flowTotalAtom)); store.set(hideSystemAtom, true); - expect(store.get(activeQueryAtom)).toEqual({ query: "", hideSystem: true }); + expect(store.get(activeQueryAtom)).toEqual({ + query: "", + hideSystem: true, + hideEncrypted: false, + }); expect(store.get(allTrafficTotalAtom)).toBe(3); store.set(hideSystemAtom, false); expect(store.get(activeQueryAtom)).toBeNull(); expect(store.get(allTrafficTotalAtom)).toBe(store.get(flowTotalAtom)); }); }); + +describe("Show in All Traffic, in Encrypted's menu", () => { + test("shows encrypted connections in All traffic by default", () => { + expect(createStore().get(hideEncryptedAtom)).toBe(false); + }); + + test("leaves encrypted connections out of All traffic, with System or alone", () => { + const store = createStore(); + store.set(allTrafficShownAtom, 5); + store.set(hideEncryptedAtom, true); + expect(store.get(activeQueryAtom)).toEqual({ + query: "", + hideSystem: false, + hideEncrypted: true, + }); + expect(store.get(allTrafficTotalAtom)).toBe(5); + store.set(hideSystemAtom, true); + expect(store.get(activeQueryAtom)).toEqual({ + query: "", + hideSystem: true, + hideEncrypted: true, + }); + store.set(hideEncryptedAtom, false); + store.set(hideSystemAtom, false); + expect(store.get(activeQueryAtom)).toBeNull(); + expect(store.get(allTrafficTotalAtom)).toBe(store.get(flowTotalAtom)); + }); +}); diff --git a/apps/yaak-proxy/lib/httpsStrip.test.ts b/apps/yaak-proxy/lib/httpsStrip.test.ts new file mode 100644 index 000000000..102d02ea5 --- /dev/null +++ b/apps/yaak-proxy/lib/httpsStrip.test.ts @@ -0,0 +1,83 @@ +import type { DecryptListEntry, Flow } from "@yaakapp-internal/proxy-lib"; +import { describe, expect, test } from "vite-plus/test"; +import { chooseOptions, connectionCount, type StripInput, stripState } from "./httpsStrip"; + +const BUILT_INS: DecryptListEntry[] = [ + { entry: "!host:*.apple.com", builtIn: true, kind: "apple" }, + { entry: "!host:*.1password.com", builtIn: true, kind: "pinned" }, +]; +const base: StripInput = { + list: BUILT_INS, + encrypted: 22, + dismissed: false, + trusted: false, + canTrust: true, +}; + +describe("the strip", () => { + test("shows while nothing is decrypted and something is encrypted", () => { + expect(stripState(base)).toBe("offer"); + expect(stripState({ ...base, trusted: true })).toBe("offer"); + }); + + test("goes once something is listed, with nothing encrypted, or dismissed", () => { + const listed = [{ entry: "app:Dia", builtIn: false }, ...BUILT_INS]; + expect(stripState({ ...base, list: listed, trusted: true })).toBe("hidden"); + expect(stripState({ ...base, encrypted: 0 })).toBe("hidden"); + expect(stripState({ ...base, dismissed: true })).toBe("hidden"); + // Exclusions alone decrypt nothing + const excluded = [{ entry: "!app:Dia", builtIn: false }, ...BUILT_INS]; + expect(stripState({ ...base, list: excluded })).toBe("offer"); + }); + + test("comes back broken, however dismissed, while something listed can't be decrypted", () => { + const listed = [{ entry: "*", builtIn: false }, ...BUILT_INS]; + expect(stripState({ ...base, list: listed, dismissed: true })).toBe("broken"); + // Not where the app can't trust it itself, nor before the status is read + expect(stripState({ ...base, list: listed, canTrust: false })).toBe("hidden"); + expect(stripState({ ...base, list: listed, trusted: null })).toBe("hidden"); + }); + + test("counts connections", () => { + expect(connectionCount(1)).toBe("1 connection"); + expect(connectionCount(1200)).toBe("1,200 connections"); + }); +}); + +describe("Choose…", () => { + const flow = (url: string, app: Partial, tunnelled = true) => ({ + url, + tunnelled, + clientName: null, + clientBundleId: null, + clientPath: null, + ...app, + }); + const dia = { clientName: "Dia", clientBundleId: "company.dia", clientPath: "/A/Dia.app" }; + const slack = { clientName: "Slack", clientBundleId: "com.slack", clientPath: "/A/Slack.app" }; + const flows = [ + flow("www.google.com:443", dia), + flow("fonts.gstatic.com:443", dia), + flow("clients2.google.com:443", dia), + flow("edgeapi.slack.com:443", slack), + // Decrypted already, so not one of the encrypted + flow("https://slack.com/api", slack, false), + // A daemon, and what a built-in keeps out, are deliberate choices elsewhere + flow("gateway.icloud.com:443", { clientName: "cloudd", clientPath: "/usr/libexec/cloudd" }), + flow("my.1password.com:443", { clientName: "1Password", clientBundleId: "com.1password" }), + ]; + + test("lists apps and sites seen, most connections first, checked while decrypted", () => { + const list = [{ entry: "host:*.slack.com", builtIn: false }, ...BUILT_INS]; + const { apps, sites } = chooseOptions(flows, list); + expect(apps.map((a) => [a.name, a.count, a.checked])).toEqual([ + ["Dia", 3, false], + ["Slack", 1, false], + ]); + expect(sites.map((s) => [s.site, s.count, s.checked])).toEqual([ + ["google.com", 2, false], + ["gstatic.com", 1, false], + ["slack.com", 1, true], + ]); + }); +}); diff --git a/apps/yaak-proxy/lib/httpsStrip.ts b/apps/yaak-proxy/lib/httpsStrip.ts new file mode 100644 index 000000000..6141ff23b --- /dev/null +++ b/apps/yaak-proxy/lib/httpsStrip.ts @@ -0,0 +1,107 @@ +import type { DecryptListEntry, Flow } from "@yaakapp-internal/proxy-lib"; +import { decryptsAnything, entryMatches, isDecrypted } from "./decrypt"; +import { connectionOf } from "./encryptedCard"; +import { isSystemFlow, siteOf } from "./search"; + +/** What the strip above the list shows */ +export type StripState = + /** Not shown */ + | "hidden" + /** HTTPS is encrypted, with Decrypt Everything and Choose… */ + | "offer" + /** The list decrypts something and the certificate isn't trusted: Trust, however dismissed */ + | "broken"; + +export interface StripInput { + list: DecryptListEntry[]; + /** Encrypted connections in the capture */ + encrypted: number; + /** Its × was clicked, remembered */ + dismissed: boolean; + /** The certificate is trusted, null until read */ + trusted: boolean | null; + /** The app can trust the certificate itself, as on macOS */ + canTrust: boolean; +} + +/** + * Shows while nothing is decrypted and some HTTPS is encrypted, until dismissed; goes once + * anything is on the list, from anywhere. A list that decrypts something while the certificate + * isn't trusted is broken, and says so whatever else holds. + */ +export function stripState({ + list, + encrypted, + dismissed, + trusted, + canTrust, +}: StripInput): StripState { + const listed = decryptsAnything(list); + if (listed && canTrust && trusted === false) return "broken"; + if (listed || encrypted === 0 || dismissed) return "hidden"; + return "offer"; +} + +/** "22 connections" */ +export function connectionCount(n: number): string { + return `${n.toLocaleString()} ${n === 1 ? "connection" : "connections"}`; +} + +export interface ChooseApp { + name: string; + bundleId: string | null; + path: string | null; + count: number; + checked: boolean; +} + +export interface ChooseSite { + site: string; + count: number; + checked: boolean; +} + +/** At most this many apps, and sites, in Choose… */ +const CHOOSE_MAX = 8; + +/** + * Choose…'s rows: the apps and sites of the encrypted connections seen, most connections first, + * each checked while the list decrypts it. System's daemons and what a built-in exclusion keeps + * out are left out, as decrypting those is deliberate, from the card or Settings. + */ +export function chooseOptions( + flows: Pick[], + list: DecryptListEntry[], +): { apps: ChooseApp[]; sites: ChooseSite[] } { + const builtIns = list.filter((e) => e.builtIn && e.entry.startsWith("!")); + const apps = new Map(); + const sites = new Map(); + for (const flow of flows) { + if (!flow.tunnelled || isSystemFlow(flow)) continue; + const c = connectionOf(flow); + const q = { host: c.host, port: c.port, app: c.app }; + if (builtIns.some((e) => entryMatches(e.entry, q))) continue; + if (c.app != null) { + const app = apps.get(c.app.name) ?? { + name: c.app.name, + bundleId: c.app.bundleId, + path: flow.clientPath, + count: 0, + checked: isDecrypted(list, { kind: "app", ...c.app }), + }; + app.count++; + apps.set(app.name, app); + } + const name = siteOf(c.label); + const site = sites.get(name) ?? { + site: name, + count: 0, + checked: isDecrypted(list, { kind: "site", site: name }), + }; + site.count++; + sites.set(name, site); + } + const top = (rows: Iterable) => + [...rows].sort((a, b) => b.count - a.count).slice(0, CHOOSE_MAX); + return { apps: top(apps.values()), sites: top(sites.values()) }; +} diff --git a/apps/yaak-proxy/lib/query.test.ts b/apps/yaak-proxy/lib/query.test.ts index ed66497b6..94b619a99 100644 --- a/apps/yaak-proxy/lib/query.test.ts +++ b/apps/yaak-proxy/lib/query.test.ts @@ -55,8 +55,17 @@ describe("terms", () => { describe("activeQuery", () => { test("is null for all traffic, and carries hiding System", () => { expect(activeQuery(" ", false)).toBeNull(); - expect(activeQuery("", true)).toEqual({ query: "", hideSystem: true }); - expect(activeQuery(" stripe ", false)).toEqual({ query: "stripe", hideSystem: false }); + expect(activeQuery("", true)).toEqual({ query: "", hideSystem: true, hideEncrypted: false }); + expect(activeQuery(" stripe ", false)).toEqual({ + query: "stripe", + hideSystem: false, + hideEncrypted: false, + }); + expect(activeQuery("", false, true)).toEqual({ + query: "", + hideSystem: false, + hideEncrypted: true, + }); }); }); diff --git a/apps/yaak-proxy/lib/query.ts b/apps/yaak-proxy/lib/query.ts index 9064edfd9..f61d13d1b 100644 --- a/apps/yaak-proxy/lib/query.ts +++ b/apps/yaak-proxy/lib/query.ts @@ -3,17 +3,23 @@ import { formatField, parseQuery } from "@yaakapp-internal/query"; /** * A search over every flow in the database, as `list_flows` takes it: the search box's text, - * which the backend parses and runs, and whether System's flows are hidden. + * which the backend parses and runs, and whether System's flows and encrypted connections are + * hidden. */ export interface FlowQuery { query: string; hideSystem: boolean; + hideEncrypted: boolean; } /** The search the list shows, or null for all traffic */ -export function activeQuery(text: string, hideSystem: boolean): FlowQuery | null { - if (text.trim() === "" && !hideSystem) return null; - return { query: text.trim(), hideSystem }; +export function activeQuery( + text: string, + hideSystem: boolean, + hideEncrypted = false, +): FlowQuery | null { + if (text.trim() === "" && !hideSystem && !hideEncrypted) return null; + return { query: text.trim(), hideSystem, hideEncrypted }; } /** `@field:value`, or `-@field:value`, the value quoted when it needs to be */ diff --git a/apps/yaak-proxy/lib/sidebarTree.test.ts b/apps/yaak-proxy/lib/sidebarTree.test.ts index 2c07425c6..080dc18fd 100644 --- a/apps/yaak-proxy/lib/sidebarTree.test.ts +++ b/apps/yaak-proxy/lib/sidebarTree.test.ts @@ -215,13 +215,20 @@ describe("decrypting from the sidebar", () => { const root = buildSidebarTree([...flows, tunnel], 5); const items = itemsById(root); - test("Not decrypted follows All traffic while there are tunnelled flows", () => { - expect((root.children ?? []).slice(0, 2).map((c) => shape(c)[0])).toEqual([ - "All traffic 5", - "Not decrypted 1", - ]); - expect(items.get("tunnelled")?.query).toBe("@is:tunnelled"); - expect(itemsById(buildSidebarTree(flows, 4)).has("tunnelled")).toBe(false); + test("Encrypted comes last, after System, while there are encrypted connections", () => { + expect((root.children ?? []).map((c) => shape(c)[0]).at(-1)).toBe("Encrypted 1"); + expect(items.get("encrypted")?.query).toBe("@is:encrypted"); + expect(itemsById(buildSidebarTree(flows, 4)).has("encrypted")).toBe(false); + }); + + test("marks what is all encrypted, once something is decrypted", () => { + expect(items.get("host:bank.example.com")?.encryptedOnly).toBe(true); + expect(items.get("site:example.com")?.encryptedOnly).toBe(true); + // Slack has decrypted flows too + expect(items.get("app:Slack")?.encryptedOnly).toBeUndefined(); + // In the first minute, when every row would be marked, none is + const onlyTunnels = itemsById(buildSidebarTree([tunnel], 1)); + expect(onlyTunnels.get("host:bank.example.com")?.encryptedOnly).toBeUndefined(); }); test("hosts, sites and apps name what their menu's Decrypt covers", () => { diff --git a/apps/yaak-proxy/lib/sidebarTree.ts b/apps/yaak-proxy/lib/sidebarTree.ts index 5ac8bd22d..0a93d9d4d 100644 --- a/apps/yaak-proxy/lib/sidebarTree.ts +++ b/apps/yaak-proxy/lib/sidebarTree.ts @@ -14,7 +14,7 @@ import { appKeyOf, folderOf, isSystemFlow, runtimeFolderOf, sidebarPath, siteOf export type SidebarKind = | "all" - | "tunnelled" + | "encrypted" | "saved" | "group" | "app" @@ -48,6 +48,11 @@ export interface SidebarItem { value?: string; /** What Decrypt in the row's menu decrypts: its host, site or app */ decrypt?: DecryptSubject; + /** + * Every flow under the row is encrypted, marked once something in the capture is decrypted, + * as in the first minute every row would be + */ + encryptedOnly?: boolean; /** The saved search a Saved row is, for renaming and deleting it */ savedId?: string; /** @@ -71,7 +76,7 @@ export const SAVED_ID = "saved"; export const APPS_ID = "apps"; export const DOMAINS_ID = "domains"; export const SYSTEM_ID = "system"; -export const TUNNELLED_ID = "tunnelled"; +export const ENCRYPTED_ID = "encrypted"; /** A row a flow sits under, with what makes it, so a rebuild only looks it up */ interface PlaceRow { @@ -196,12 +201,14 @@ function programPath(flow: Flow): string | undefined { interface Trie { item: SidebarItem; children: Map; + /** Encrypted flows under it */ + encrypted?: number; } const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); /** Counts a flow under `parent`'s child `row`, making the child the first time */ -function child(parent: Trie, row: PlaceRow): Trie { +function child(parent: Trie, row: PlaceRow, encrypted: boolean): Trie { let node = parent.children.get(row.id); if (node == null) { node = { @@ -224,13 +231,25 @@ function child(parent: Trie, row: PlaceRow): Trie { delete node.item.path; } node.item.count++; + if (encrypted) node.encrypted = (node.encrypted ?? 0) + 1; return node; } /** Adds a flow under `parent` by where it went: its site, then its host, then its path */ -function addPlace(parent: Trie, rows: PlaceRow[]) { +function addPlace(parent: Trie, rows: PlaceRow[], encrypted: boolean) { let node = parent; - for (const row of rows) node = child(node, row); + for (const row of rows) node = child(node, row, encrypted); +} + +/** Marks the apps, sites and hosts whose every flow is encrypted */ +function markEncrypted(trie: Trie) { + for (const c of trie.children.values()) { + const kind = c.item.kind; + if ((kind === "app" || kind === "site" || kind === "host") && c.encrypted === c.item.count) { + c.item.encryptedOnly = true; + } + markEncrypted(c); + } } function toNodes( @@ -264,9 +283,8 @@ function group(id: string, label: string): Trie { /** * The sidebar: All traffic, then Apps, each app opening to its own sites, hosts and paths, then * Domains by site, host and path, then System, macOS's daemons, apart from the apps. A group with - * nothing in it is left out. Not decrypted, after All traffic, gathers the HTTPS passed through - * undecrypted, when there is any, and Saved lists the saved searches, each with its count from - * the database. `total` is All traffic's count: every flow it covers in the database. + * nothing in it is left out. Encrypted, last, gathers the HTTPS passed through undecrypted, + * when there is any, and Saved lists the saved searches, each with its count from the database. `total` is All traffic's count: every flow it covers in the database. */ export function buildSidebarTree( flows: Flow[], @@ -284,12 +302,12 @@ export function buildSidebarTree( const domains = group(DOMAINS_ID, "Domains"); const system = { ...group(SYSTEM_ID, "System") }; system.item.query = fieldTerm("is", "system"); - let tunnelled = 0; + let encrypted = 0; for (const flow of flows) { const place = placeOf(flow); - if (flow.tunnelled) tunnelled++; - addPlace(domains, place.rows); + if (flow.tunnelled) encrypted++; + addPlace(domains, place.rows, flow.tunnelled); if (place.app == null) continue; const parent = place.system ? system : apps; const id = `app:${place.app}`; @@ -318,8 +336,12 @@ export function buildSidebarTree( delete app.item.path; } app.item.count++; + if (flow.tunnelled) app.encrypted = (app.encrypted ?? 0) + 1; parent.item.count++; - addPlace(app, place.appRows); + addPlace(app, place.appRows, flow.tunnelled); + } + if (encrypted > 0 && encrypted < flows.length) { + for (const g of [apps, domains, system]) markEncrypted(g); } for (const site of domains.children.values()) domains.item.count += site.children.size; @@ -329,20 +351,6 @@ export function buildSidebarTree( depth: 1, draggable: false, }); - if (tunnelled > 0) { - root.children?.push({ - item: { - id: TUNNELLED_ID, - kind: "tunnelled", - label: "Not decrypted", - count: tunnelled, - query: fieldTerm("is", "tunnelled"), - }, - parent: root, - depth: 1, - draggable: false, - }); - } if (saved.length > 0) { const node: TreeNode = { item: { id: SAVED_ID, kind: "group", label: "Saved", count: 0, query: "" }, @@ -378,6 +386,20 @@ export function buildSidebarTree( node.children = toNodes(g.children.values(), node); root.children?.push(node); } + if (encrypted > 0) { + root.children?.push({ + item: { + id: ENCRYPTED_ID, + kind: "encrypted", + label: "Encrypted", + count: encrypted, + query: fieldTerm("is", "encrypted"), + }, + parent: root, + depth: 1, + draggable: false, + }); + } return root; } diff --git a/apps/yaak-proxy/lib/store.ts b/apps/yaak-proxy/lib/store.ts index fe7b12864..831bc413f 100644 --- a/apps/yaak-proxy/lib/store.ts +++ b/apps/yaak-proxy/lib/store.ts @@ -81,9 +81,14 @@ export const hideSystemAtom = atomWithStorage("sidebar_hide_system", false, unde getOnInit: true, }); +/** Encrypted connections are left out of All traffic, by Show in All Traffic in Encrypted's menu */ +export const hideEncryptedAtom = atomWithStorage("sidebar_hide_encrypted", false, undefined, { + getOnInit: true, +}); + /** The search the list shows, from the filter applied, or null for all traffic */ export const activeQueryAtom = atom((get) => - activeQuery(get(appliedFilterAtom), get(hideSystemAtom)), + activeQuery(get(appliedFilterAtom), get(hideSystemAtom), get(hideEncryptedAtom)), ); /** The saved searches, in their order */ @@ -103,13 +108,16 @@ const savedRowsAtom = atom((get) => { })); }); -/** Flows All traffic covers in the database: every one, or all but System's while hidden */ +/** + * Flows All traffic covers in the database: every one, or all but System's and the encrypted + * connections while either is hidden + */ export const allTrafficTotalAtom = atom((get) => - get(hideSystemAtom) ? get(nonSystemTotalAtom) : get(flowTotalAtom), + get(hideSystemAtom) || get(hideEncryptedAtom) ? get(allTrafficShownAtom) : get(flowTotalAtom), ); -/** Flows in the database not from System, counted while System is hidden */ -export const nonSystemTotalAtom = atom(0); +/** Flows in the database All traffic shows, counted while System or Encrypted is hidden */ +export const allTrafficShownAtom = atom(0); /** * The flows the sidebar counts: the window's, taken at most once a second, so a burst of traffic @@ -450,9 +458,11 @@ export function syncFlows(store: Store) { win.total === res.total ? win : { ...win, total: res.total }, ); }); - if (store.get(hideSystemAtom)) { - void rpc("list_flows", { hideSystem: true, limit: 0 }).then((res) => { - if (thisLoad === loads) store.set(nonSystemTotalAtom, res.matching ?? res.total); + const hideSystem = store.get(hideSystemAtom); + const hideEncrypted = store.get(hideEncryptedAtom); + if (hideSystem || hideEncrypted) { + void rpc("list_flows", { hideSystem, hideEncrypted, limit: 0 }).then((res) => { + if (thisLoad === loads) store.set(allTrafficShownAtom, res.matching ?? res.total); }); } countSaved(); @@ -485,7 +495,10 @@ export function syncFlows(store: Store) { return; } counting = true; - rpc("count_saved_queries", { hideSystem: store.get(hideSystemAtom) }) + rpc("count_saved_queries", { + hideSystem: store.get(hideSystemAtom), + hideEncrypted: store.get(hideEncryptedAtom), + }) .then((counts) => store.set(savedCountsAtom, Object.fromEntries(counts.map((c) => [c.id, c.count]))), ) @@ -643,9 +656,12 @@ export function syncFlows(store: Store) { // Read again, it keeps showing its rows until the new ones are in. const win = (keepRows ? store.get(searchWindowAtom)?.win : null) ?? emptyWindow; store.set(searchWindowAtom, { key, query, win, loading: true, buffered: [] }); - void rpc("watch_query", { key, query: query.query, hideSystem: query.hideSystem }).catch( - (err) => console.warn("Failed to watch the search", err), - ); + void rpc("watch_query", { + key, + query: query.query, + hideSystem: query.hideSystem, + hideEncrypted: query.hideEncrypted, + }).catch((err) => console.warn("Failed to watch the search", err)); const settle = (page: FlowWindow | null) => store.set(searchWindowAtom, (s) => { if (s?.key !== key) return s; @@ -693,6 +709,9 @@ export function syncFlows(store: Store) { store.sub(hideSystemAtom, () => { if (loaded) recount(); }); + store.sub(hideEncryptedAtom, () => { + if (loaded) recount(); + }); // A search counting back from now, `@time:<5m`, is read again while it is shown and the list // follows new traffic, so flows leave it and join it as they age. Saved ones are counted @@ -772,7 +791,7 @@ let reloadFlows: (() => void) | null = null; /** Empties the list, the search's too, and their counts, as a clear leaves them */ const emptyFlowsAtom = atom(null, (_get, set) => { set(flowWindowAtom, emptyWindow); - set(nonSystemTotalAtom, 0); + set(allTrafficShownAtom, 0); set(searchWindowAtom, (s) => (s == null ? s : { ...s, win: emptyWindow, buffered: [] })); set(sortedWindowAtom, (s) => s == null ? s : { ...s, flows: [], boundary: null, cursor: null, buffered: [] }, diff --git a/packages/ui/src/components/CountBadge.tsx b/packages/ui/src/components/CountBadge.tsx index a64d968bc..105ca4293 100644 --- a/packages/ui/src/components/CountBadge.tsx +++ b/packages/ui/src/components/CountBadge.tsx @@ -2,7 +2,8 @@ import type { Color } from "@yaakapp-internal/plugins"; import classNames from "classnames"; interface Props { - count: number | true; + /** A number, a dot for `true`, or a short word such as a body's type */ + count: number | true | string; count2?: number | true; className?: string; color?: Color; diff --git a/packages/ui/src/components/Dropdown.tsx b/packages/ui/src/components/Dropdown.tsx index f0d7a76cc..7f5dd2611 100644 --- a/packages/ui/src/components/Dropdown.tsx +++ b/packages/ui/src/components/Dropdown.tsx @@ -98,6 +98,8 @@ export interface DropdownProps { fullWidth?: boolean; hotKeyAction?: HotkeyAction; onOpen?: () => void; + /** The menu closed, however it was closed */ + onClose?: () => void; } export interface DropdownRef { @@ -117,7 +119,7 @@ const openAtom = atom(null); const openStore = createStore(); export const Dropdown = forwardRef(function Dropdown( - { children, items, hotKeyAction, fullWidth, onOpen }: DropdownProps, + { children, items, hotKeyAction, fullWidth, onOpen, onClose }: DropdownProps, ref, ) { const id = useRef(generateId()); @@ -129,9 +131,10 @@ export const Dropdown = forwardRef(function Dropdown const newIsOpen = globalOpenId === id.current; if (newIsOpen !== isOpen) { setIsOpen(newIsOpen); + if (!newIsOpen) onClose?.(); } }); - }, [isOpen]); + }, [isOpen, onClose]); // const [isOpen, _setIsOpen] = useState(false); const [defaultSelectedIndex, setDefaultSelectedIndex] = useState(null); diff --git a/packages/ui/src/components/http/HttpRequestPaneView.tsx b/packages/ui/src/components/http/HttpRequestPaneView.tsx index fb263f12e..8d08f03c8 100644 --- a/packages/ui/src/components/http/HttpRequestPaneView.tsx +++ b/packages/ui/src/components/http/HttpRequestPaneView.tsx @@ -106,6 +106,8 @@ export interface HttpRequestPaneViewProps { /** Tabs only the app has, after the shared ones */ extraTabs?: ExtraPaneTab[]; + /** Tabs only the app has, before the shared ones, like the proxy's Info */ + leadingTabs?: ExtraPaneTab[]; /** Replaces the URL bar, as the proxy's card shows a captured request's method and URL */ urlLine?: ReactNode; @@ -165,6 +167,7 @@ export function HttpRequestPaneView({ authTabs, authContent, extraTabs, + leadingTabs, urlLine, defaultTab, activeTab, @@ -187,6 +190,7 @@ export function HttpRequestPaneView({ const headerCount = request.headers.filter((h) => h.name && h.enabled !== false).length; const tabs = useMemo( () => [ + ...(leadingTabs?.map((t) => t.tab) ?? []), bodyTab ?? { value: REQUEST_TAB_BODY, label: bodyTypeLabel(request.bodyType, request.contentType), @@ -219,6 +223,7 @@ export function HttpRequestPaneView({ authTabs, bodyTab, extraTabs, + leadingTabs, headerCount, headersTabs, numParams, @@ -319,7 +324,7 @@ export function HttpRequestPaneView({ {renderBody ? renderBody(sharedBody) : sharedBody} - {extraTabs?.map(({ tab, content }) => ( + {[...(leadingTabs ?? []), ...(extraTabs ?? [])].map(({ tab, content }) => ( {content} diff --git a/packages/ui/src/components/http/HttpResponsePaneView.tsx b/packages/ui/src/components/http/HttpResponsePaneView.tsx index f1509e78a..3df89fd57 100644 --- a/packages/ui/src/components/http/HttpResponsePaneView.tsx +++ b/packages/ui/src/components/http/HttpResponsePaneView.tsx @@ -66,6 +66,8 @@ export interface HttpResponsePaneViewProps { cookieCounts?: { sent: number; received: number }; /** Tabs only the app has, after the shared ones */ extraTabs?: ExtraPaneTab[]; + /** The body tab as the app names it, where it has no menu of its own */ + bodyTab?: TabItem; textViewer?: ResponseTextViewerExtras; /** Viewers only this app has for a response body, like the client's event stream viewer */ renderBodyViewer?: EditorHost["renderBodyViewer"]; @@ -111,6 +113,7 @@ export function HttpResponsePaneView({ onCancel, cookieCounts: cookieCountsOverride, extraTabs, + bodyTab, textViewer, renderBodyViewer, infoRows, @@ -166,7 +169,7 @@ export function HttpResponsePaneView({ const headerCount = response?.headers.length ?? 0; return [ onViewModeChange == null && itemsAfter.length === 0 - ? { value: RESPONSE_TAB_BODY, label: "Response" } + ? (bodyTab ?? { value: RESPONSE_TAB_BODY, label: "Response" }) : { value: RESPONSE_TAB_BODY, options: { @@ -212,6 +215,7 @@ export function HttpResponsePaneView({ ...(extraTabs?.map((t) => t.tab) ?? []), ]; }, [ + bodyTab, cookieCounts, extraTabs, mimeType,