Yaak Proxy: HTTPS decryption UI, and Info and Body in the card's tabs

The strip, quiet encrypted rows, warning rows for refused handshakes, the
encrypted card with named actions, trust at the first Decrypt, the toast
with Undo and Reconnect, the pill's Decrypt everything switch, Settings'
Decryption section, the Encrypted sidebar row, and two-step setup. Both
body tabs read Body with a type badge; Info leads the request half with
the timeline folded in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gregory SchierandClaude Opus 5.5 authored and Stetta task tk_mwb56agvne2hw21h committed 2026-11-10 22:30:40 -07:00
1 parent 5148b0f0ba
commit b0425069f4
43 files changed
+3019 -586

No files matched your search

+181 -34
View File
@@ -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<SeenTargets | undefined>(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 (
<div className={classNames(className, "grid gap-2")}>
<EntryInput
seenRef={seenRef}
list={list}
disabled={save.isPending}
onAdd={(entry) => save.mutate({ add: [entry], remove: [] })}
<>
<CertificateRow />
<SettingRowBoolean
title="Decrypt everything"
description="Except built-in exclusions"
checked={everything}
disabled={busy}
onChange={(on) =>
void change(
everythingEdit(on),
on ? "Decrypting everything" : "Stopped decrypting everything",
on ? "Except Apple services and apps that pin" : undefined,
)
}
/>
<div className="rounded-md border border-border-subtle divide-y divide-border-subtle">
{list.length === 0 && (
<div className="px-3 py-2 text-sm text-text-subtle">Nothing decrypted</div>
<div className="py-4 grid gap-2">
<div className="flex items-center text-text">
Hosts and apps
<span className="ml-auto text-sm text-text-subtlest">New connections</span>
</div>
<EntryInput
seenRef={seenRef}
list={list}
disabled={busy}
onAdd={(entry) =>
void change(
{ add: [entry], remove: [] },
entry.startsWith("!")
? `Excluding ${entrySubject(entry.slice(1))}`
: `Decrypting ${entrySubject(entry)}`,
)
}
/>
<div className="rounded-md border border-border-subtle divide-y divide-border-subtle">
{own.length === 0 && (
<div className="px-3 py-2 text-sm text-text-subtle">Nothing listed</div>
)}
{own.map((e) => (
<EntryRow key={e.entry} entry={e} disabled={busy} onRemove={() => remove(e.entry)} />
))}
{builtIns.length > 0 && <BuiltIns entries={builtIns} disabled={busy} onRemove={remove} />}
</div>
</div>
</>
);
}
/** 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 (
<SettingRow title="Certificate" description="Yaak Proxy CA">
{null}
</SettingRow>
);
return (
<SettingRow title="Certificate" description="Yaak Proxy CA">
<div className="flex items-center gap-2">
{status.trusted ? (
<span className="flex items-center gap-1 text-sm text-success">
<Icon icon="badge_check" size="sm" />
Trusted
</span>
) : (
<span className="text-sm text-text-subtle">Not trusted</span>
)}
{list.map((e) => (
{!status.trusted && canTrustHere() ? (
<Button size="2xs" color="primary" isLoading={trusting} onClick={() => trust.mutate({})}>
{trusting ? "Waiting for macOS" : "Trust"}
</Button>
) : (
<Button
size="2xs"
variant="border"
onClick={() =>
void revealPath(status.certPath, (message) =>
showToast({ id: "reveal-ca", color: "danger", message }),
)
}
>
Reveal
</Button>
)}
</div>
</SettingRow>
);
}
/** 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 (
<>
<button
type="button"
className="w-full flex items-center gap-2 h-xs pl-3 pr-2 text-sm text-text-subtle hover:bg-surface-highlight/50"
onClick={() => setOpen(!open)}
>
<Icon icon={open ? "chevron_down" : "chevron_right"} size="sm" className="shrink-0" />
{entries.length} built-in exclusions
<span className="ml-auto text-xs text-text-subtlest">{summary}</span>
</button>
{open &&
entries.map((e) => (
<EntryRow
key={e.entry}
entry={e}
disabled={save.isPending}
onRemove={() => remove(e.entry)}
disabled={disabled}
onRemove={() => onRemove(e.entry)}
/>
))}
</div>
</div>
</>
);
}
@@ -81,12 +230,10 @@ function EntryRow({
<span className={classNames("truncate", entry.entry !== "*" && "font-mono text-xs")}>
{entryLabel(entry.entry)}
</span>
{entry.builtIn && (
<Tooltip wrap content="Ships with Yaak Proxy" delay={CONTROL_TOOLTIP_DELAY}>
<span className="shrink-0 text-2xs rounded px-1 border border-border-subtle">
built-in
</span>
</Tooltip>
{entry.builtIn && entry.kind != null && (
<span className="shrink-0 text-2xs rounded px-1 border border-border-subtle">
{entry.kind === "apple" ? "Apple" : "Pinned"}
</span>
)}
<IconButton
size="xs"
@@ -160,7 +307,7 @@ function EntryInput({
size="sm"
stateKey={null}
language={null}
placeholder="Add host, app or *"
placeholder="Add host or app"
extensions={extensions}
onChange={() => setError(null)}
/>
@@ -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<string | null>(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 (
<div className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)] min-h-0">
<div
className={classNames(
"flex items-center gap-2 px-3 min-h-[calc(var(--height-sm)+0.25rem)]",
"font-mono text-sm whitespace-nowrap overflow-x-auto hide-scrollbars",
)}
>
<Icon
icon={rejection ? "shield_off" : "lock"}
size="sm"
className={rejection ? "text-warning" : "text-text-subtlest"}
/>
<span className="text-text select-text cursor-text">
{connection.host.includes(":") ? `[${connection.host}]` : connection.host}
<span className="text-text-subtlest">:{connection.port}</span>
</span>
</div>
<StatusLine flow={flow} rejected={flow.tlsRejected != null} />
<div className="mx-3 border-t border-border-subtle overflow-auto">
<div className="mx-auto max-w-[27rem] py-10 grid gap-2">
<div className="text-sm text-text mb-1">
{model.why}
{model.whyEntry != null && (
<>
{" "}
<InlineCode>{model.whyEntry}</InlineCode>
</>
)}
{model.whySub != null && <span className="text-text-subtle"> · {model.whySub}</span>}
</div>
{model.fact != null && (
<div className="text-sm text-text-subtle -mt-1 mb-1">{model.fact}</div>
)}
{model.actions.map((action) => (
<ActionButton
key={action.label}
action={
busy === action.label && trusting
? { ...action, label: "Waiting for macOS" }
: action
}
flow={flow}
loading={
busy === action.label ||
(action.kind === "trust" && trust.isPending) ||
(action.kind === "try_again" && forget.isPending) ||
(action.kind === "reconnect" && reconnect.isPending)
}
disabled={busy != null && busy !== action.label}
onClick={() => void run(action)}
/>
))}
{model.foot.length > 0 && (
<div className="text-xs text-text-subtlest mt-0.5">{model.foot.join(" · ")}</div>
)}
{flow.error != null && flow.tlsRejected == null && (
<div className="text-xs text-danger mt-1">{flow.error}</div>
)}
</div>
</div>
</div>
);
}
const ICONS: Record<Exclude<CardAction["icon"], "app">, 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 ? (
<Icon icon="box" size="sm" />
) : (
<AppIcon path={flow.clientPath} className="size-4" />
)
) : (
<Icon icon={ICONS[action.icon]} size="sm" />
);
return (
<Button
size="xs"
variant="border"
color={action.primary ? "primary" : "default"}
justify="start"
className={classNames("w-full", action.dashed && "border-dashed!")}
isLoading={loading}
disabled={disabled}
leftSlot={loading ? undefined : icon}
rightSlot={
action.hint == null ? undefined : (
<span
className={classNames(
"ml-auto pl-3 font-mono text-xs text-text-subtlest truncate max-w-[50%]",
action.removes && "line-through",
)}
>
{action.hint}
</span>
)
}
onClick={onClick}
>
{action.label}
</Button>
);
}
/** 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 (
<div
className={classNames(
"grid grid-cols-[minmax(0,1fr)_auto] items-center pl-3",
"min-h-[calc(var(--height-sm)+0.25rem)] font-mono text-sm text-text-subtle whitespace-nowrap",
)}
>
<div className="flex items-center gap-2 overflow-x-auto hide-scrollbars">
<span className={rejected ? "text-warning" : undefined}>
{rejected ? "REJECTED" : "ENCRYPTED"}
</span>
<span>&bull;</span>
{open ? (
<span>open {openFor == null ? "" : formatMillis(openFor)}</span>
) : (
<>
<span>{flow.elapsedMs == null ? "--" : formatMillis(flow.elapsedMs)}</span>
<span>&bull;</span>
<span className="flex items-center gap-1">
<Icon icon="arrow_up" size="xs" className="text-text-subtlest" />
{formatSize(flow.requestSize)}
<Icon icon="arrow_down" size="xs" className="text-text-subtlest ml-1" />
{formatSize(flow.responseSize)}
</span>
</>
)}
</div>
<div className="m-0.5 pr-2.5">
<Dropdown items={flowActions(flow).slice(2)}>
<IconButton size="sm" icon="ellipsis" iconColor="secondary" title="Actions" tooltip />
</Dropdown>
</div>
</div>
);
}
/** 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;
}
+17 -107
View File
@@ -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() {
<div className="w-full max-w-[40rem]">
<div className="text-xl font-semibold mb-1.5">Set up Yaak Proxy</div>
<div className="text-text-subtle text-sm leading-relaxed mb-4">
{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."}
</div>
<div className="rounded-lg border border-border-subtle bg-surface divide-y divide-border-subtle">
<Step
n={1}
state={trustState}
title={trusted ? "Certificate trusted" : "Trust the certificate"}
description={
trusted
? "Yaak Proxy CA, in your login keychain."
: trustState === "wait"
? "Waiting for your password in the macOS prompt."
: trustState === "fail"
? (trust.error?.message ?? "The certificate was not trusted.")
: "So HTTPS reads as plain text. macOS asks for your login password once."
}
action={
trustState === "next" || trustState === "fail" ? (
<Button
size="2xs"
variant={trustState === "fail" ? "border" : "solid"}
color={trustState === "fail" ? "default" : "primary"}
onClick={() => trust.mutate({})}
>
{trustState === "fail" ? "Try Again" : "Trust Certificate"}
</Button>
) : null
}
/>
{systemSupported && (
<Step
n={2}
n={1}
state={systemState}
title={systemOn ? "System proxy on" : "Turn on the system proxy"}
description={
@@ -128,49 +80,7 @@ export function FirstRun() {
/>
)}
<Step
n={systemSupported ? 3 : 2}
state={decrypt.state}
title="What to decrypt"
description={
everything ? (
<Tooltip
wrap
content="Except the built-in exclusions"
delay={CONTROL_TOOLTIP_DELAY}
>
<span>Everything</span>
</Tooltip>
) : (
<Tooltip wrap content="From a flow or the sidebar" delay={CONTROL_TOOLTIP_DELAY}>
<span>Pick as they appear</span>
</Tooltip>
)
}
action={
everything ? (
<Button
size="2xs"
variant="border"
isLoading={saveDecryptList.isPending}
onClick={() => setEverything(false)}
>
Pick As They Appear
</Button>
) : (
<Button
size="2xs"
variant="border"
color={decrypt.state === "next" ? "primary" : "default"}
isLoading={saveDecryptList.isPending}
onClick={() => setEverything(true)}
>
Everything
</Button>
)
}
/>
<Step
n={systemSupported ? 4 : 3}
n={systemSupported ? 2 : 1}
state={trafficState}
title={
encrypting != null
@@ -183,12 +93,12 @@ export function FirstRun() {
encrypting != null
? "Traffic captured before this version is being encrypted on disk. Recording starts when it's done."
: trafficState === "fail"
? (retry.error?.message ?? proxyState?.startError ?? "The proxy has not started.")
: !recording
? "Recording is paused, so traffic passes through without being stored."
: trafficState === "wait"
? "Open a website or send a request, and its flow appears here."
: "Flows appear here as apps send requests."
? (retry.error?.message ?? proxyState?.startError ?? "The proxy has not started.")
: !recording
? "Recording is paused, so traffic passes through without being stored."
: trafficState === "wait"
? "Open a website or send a request, and its flow appears here."
: "Flows appear here as apps send requests."
}
action={
trafficState === "fail" ? (
+30 -16
View File
@@ -15,6 +15,7 @@ import {
parseCookieHeaders,
ResponseCookies,
SplitLayout,
type TabItem,
Tooltip,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
@@ -22,7 +23,7 @@ import { useAtomValue, useSetAtom, useStore } from "jotai";
import { useCallback, useMemo } from "react";
import { BODY_TYPE_FORM_URLENCODED } from "@yaakapp-internal/ui/httpTypes";
import { placeTabsAtom } from "../lib/atoms";
import { findHeader } from "../lib/body";
import { bodyTypeBadge, findHeader } from "../lib/body";
import {
browserTabMemory,
defaultTabFor,
@@ -32,13 +33,12 @@ import {
REQUEST_TAB_BODY,
REQUEST_TAB_COOKIES,
REQUEST_TAB_HEADERS,
REQUEST_TAB_INFO,
REQUEST_TAB_PARAMS,
RESPONSE_TAB_BODY,
RESPONSE_TAB_COOKIES,
RESPONSE_TAB_HEADERS,
RESPONSE_TAB_INFO,
RESPONSE_TAB_MESSAGES,
RESPONSE_TAB_TIMELINE,
} from "../lib/defaultTab";
import { saveDialogFlowAtom, useFlowActions } from "../lib/flowActions";
import {
@@ -54,7 +54,6 @@ import { FlowBodyViewer, useFlowBody } from "./FlowBodyViewer";
import { FlowInfo } from "./FlowInfo";
import { FlowRequestBody } from "./FlowRequestBody";
import { FlowMessages } from "./FlowMessages";
import { FlowTimeline } from "./FlowTimeline";
import { ProxyEditorHost } from "./ProxyEditorHost";
/**
@@ -150,7 +149,15 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
],
[cookies, sentCount],
);
const leadingTabs = useMemo<ExtraPaneTab[]>(
() => [{ tab: { value: REQUEST_TAB_INFO, label: "Info" }, content: <FlowInfo flow={flow} /> }],
[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 : <CountBadge count={badge} />,
};
}
/** 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<ExtraPaneTab[]>(
() => [
{
tab: { value: RESPONSE_TAB_TIMELINE, label: "Timeline" },
content: <FlowTimeline flow={flow} />,
},
{ tab: { value: RESPONSE_TAB_INFO, label: "Info" }, content: <FlowInfo flow={flow} /> },
...(messagesLabel == null
() =>
messagesLabel == null
? []
: [
{
@@ -269,16 +285,13 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
},
content: <FlowMessages flow={flow} />,
},
]),
],
],
[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={() => <FlowBodyViewer flow={flow} which="response" />}
statusLineCenter={
flow.responseTruncated ? (
+4 -3
View File
@@ -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
<Keys keys={["⌘", "⇧", "K"]} />
</div>
</EmptyState>
) : flow.tunnelled ? (
<NotDecrypted flow={flow} />
) : flow.tunnelled || isRejection(flow) ? (
<EncryptedCard key={flow.id} flow={flow} />
) : (
<FlowCard flow={flow} listBeside={sideBySide} />
)}
+58 -3
View File
@@ -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 }),
]}
</Section>
<Timeline flow={flow} />
</div>
);
}
const PHASE_ICONS: Record<PhaseKind, { icon: IconProps["icon"]; color: IconProps["color"] }> = {
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 (
<DetailsBanner defaultOpen summary={<h2>Timeline</h2>}>
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto_auto] gap-x-3 gap-y-1.5 items-center text-sm py-1">
{phases.map((p) => {
const { icon, color } = PHASE_ICONS[p.kind];
return (
<Fragment key={p.key}>
<Icon icon={icon} color={color} size="sm" />
{p.detail == null ? (
<span className="truncate">{p.label}</span>
) : (
<Tooltip wrap content={p.detail} delay={CONTROL_TOOLTIP_DELAY}>
<span className="truncate">
{p.label}
<span className="text-text-subtlest">{` ${p.detail}`}</span>
</span>
</Tooltip>
)}
<span className="font-mono text-xs tabular-nums text-right">
{p.kind === "start" ? "" : `+${formatMillis(p.tookMs)}`}
</span>
<span className="font-mono text-xs tabular-nums text-right text-text-subtlest">
{formatMillis(p.atMs)}
</span>
</Fragment>
);
})}
</div>
</DetailsBanner>
);
}
/** A row as `KeyValueRows` takes it */
type Row = ReactElement<HTMLAttributes<HTMLTableColElement>>;
+155 -15
View File
@@ -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({
<FlowMark flow={flow} />
{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
<div key={c.id} data-column={c.id} className="contents">
<FlowCell column={c.id} flow={flow} span={spanReason && c.id === "type"} />
<FlowCell
column={c.id}
flow={flow}
selected={selected}
span={(spanReason && c.id === "type") || (spanHost && c.id === "host")}
/>
</div>
);
})}
@@ -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 = (
<span className="flex items-center justify-center">
{flow.error != null ? (
{rejected ? (
<Icon icon="shield_off" size="sm" color="warning" />
) : flow.error != null ? (
<Icon icon="circle_alert" size="sm" color="danger" />
) : flow.cancelled ? (
<Icon icon="circle_off" size="sm" className="text-text-subtlest" />
@@ -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 <span className={cell} />;
return (
<Tooltip wrap content={flow.clientName ?? flow.clientPath} delay={CONTROL_TOOLTIP_DELAY}>
<span className={classNames(cell, "flex items-center")}>
<AppIcon
path={flow.clientPath}
className={classNames(
"size-5",
!selected && "opacity-50 group-hover/row:opacity-100",
)}
/>
</span>
</Tooltip>
);
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 (
<Truncated text={label}>
<span
className={classNames(
cell,
"rtl text-left",
span && "col-span-2",
selected ? "text-text" : "text-text-subtlest group-hover/row:text-text-subtle",
)}
>
<bdi>
{host}
{port != null && <span className="opacity-60">{port}</span>}
</bdi>
</span>
</Truncated>
);
}
case "size":
return (
<span className={classNames(quiet, "text-right")}>
{isOpenTunnel(flow) ? "" : formatSize(flow.responseSize)}
</span>
);
case "requestSize":
return (
<span className={classNames(quiet, "text-right")}>
{isOpenTunnel(flow) ? "" : formatSize(flow.requestSize)}
</span>
);
case "time": {
const ms = isOpenTunnel(flow)
? Date.now() - Date.parse(utcTimestamp(flow.startedAt))
: flow.elapsedMs;
return (
<span className={classNames(quiet, "text-right")}>
{ms == null || !Number.isFinite(ms) ? "" : formatMillis(Math.max(0, ms))}
</span>
);
}
default:
return <span className={cell} />;
}
}
/**
* 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 <span className={cell} />;
case "type":
return (
<span className={classNames(cell, "text-warning text-xs", span && "col-span-2")}>
Rejected certificate
</span>
);
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 <span className={classNames(cell, "text-text-subtlest")}>Not decrypted</span>;
}
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 ? (
<span className={classNames(cell, mono, "text-text-subtlest")}>--</span>
) : (
<HttpStatusTagRaw status={flow.status ?? 0} short className={classNames(cell, mono)} />
@@ -1104,7 +1244,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
</span>
);
case "query": {
const query = flow.tunnelled ? "" : (urlParts(flow.url).query ?? "");
const query = urlParts(flow.url).query ?? "";
return (
<Truncated text={query}>
<span className={classNames(cell, "text-text-subtle")}>{query}</span>
+10 -3
View File
@@ -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 (
<EmptyStateText
@@ -1,71 +0,0 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
import {
CONTROL_TOOLTIP_DELAY,
EventViewer,
EventViewerRow,
formatMillis,
Icon,
type IconProps,
Tooltip,
} from "@yaakapp-internal/ui";
import { useMemo } from "react";
import { type FlowPhase, flowPhases, type PhaseKind } from "../lib/phases";
const ICONS: Record<PhaseKind, { icon: IconProps["icon"]; color: IconProps["color"] }> = {
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 (
<EventViewer<FlowPhase>
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 (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon icon={icon} color={color} size="sm" />}
content={
event.detail == null ? (
<span>{event.label}</span>
) : (
<Tooltip
wrap
content={event.detail}
delay={CONTROL_TOOLTIP_DELAY}
>
<span>
{event.label}
<span className="text-text-subtlest">{` ${event.detail}`}</span>
</span>
</Tooltip>
)
}
columns="auto minmax(0,1fr) 6rem 6rem"
cells={{
trailing: [
<span key="took" className="tabular-nums">
{event.kind === "start" ? "" : `+${formatMillis(event.tookMs)}`}
</span>,
<span key="at" className="tabular-nums opacity-50">
{formatMillis(event.atMs)}
</span>,
],
}}
/>
);
}}
/>
);
}
+174
View File
@@ -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 (
<div className={STRIP}>
<Icon icon="shield_off" size="sm" color="warning" className="shrink-0" />
<span className="text-text shrink-0">Certificate not trusted</span>
<span className="truncate">Required to decrypt</span>
<Button
size="2xs"
color="primary"
className="ml-auto shrink-0"
isLoading={trusting}
onClick={() => trust.mutate({})}
>
{trusting ? "Waiting for macOS" : "Trust"}
</Button>
</div>
);
}
const items: DropdownItem[] = [
{ type: "separator", label: "Apps", hidden: options.apps.length === 0 },
...options.apps.map((app): DropdownItem => ({
label:
app.path == null ? (
app.name
) : (
<span className="flex items-center gap-2">
<AppIcon path={app.path} className="size-4" />
{app.name}
</span>
),
keepOpenOnSelect: true,
leftSlot: <Check on={app.checked} />,
rightSlot: <Count n={app.count} />,
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: <span className="font-mono text-xs">*.{site.site}</span>,
keepOpenOnSelect: true,
leftSlot: <Check on={site.checked} />,
rightSlot: <Count n={site.count} />,
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: <Icon icon="settings" />,
onSelect: () => openSettings(SETTINGS_TAB_PROXY),
},
];
return (
<div className={STRIP}>
<Icon icon="lock" size="sm" className="shrink-0 text-text-subtle" />
<span className="text-text shrink-0">HTTPS is encrypted</span>
<span className="font-mono text-xs text-text-subtlest truncate">
{connectionCount(encrypted)}
</span>
<div className="ml-auto flex items-center gap-1.5 shrink-0">
<Tooltip
wrap
content="New connections, except Apple services and apps that pin"
delay={CONTROL_TOOLTIP_DELAY}
>
<Button
size="2xs"
variant={trusting ? "border" : "solid"}
color={trusting ? "default" : "primary"}
isLoading={trusting}
onClick={() =>
void apply({
edit: everythingEdit(true),
message: "Decrypting everything",
sub: "Except Apple services and apps that pin",
})
}
>
{trusting ? "Waiting for macOS" : "Decrypt Everything"}
</Button>
</Tooltip>
<Dropdown items={items} onOpen={() => setChoosing(true)} onClose={() => setChoosing(false)}>
<Button size="2xs" variant="border">
Choose…
</Button>
</Dropdown>
<IconButton
size="xs"
icon="x"
iconColor="secondary"
title="Not now"
tooltip
onClick={() => setDismissed(true)}
/>
</div>
</div>
);
}
function Check({ on }: { on: boolean }) {
return on ? (
<Icon icon="check" size="sm" />
) : (
<span aria-hidden className="inline-block w-4 h-4" />
);
}
function Count({ n }: { n: number }) {
return <span className="pl-4 font-mono text-xs text-text-subtlest">{n.toLocaleString()}</span>;
}
@@ -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 (
<EmptyState>
<div className="grid justify-items-center gap-2">
<div className="flex items-center gap-1.5 text-text-subtle">
<Icon icon="lock" size="sm" />
Not decrypted
</div>
<div className="font-mono text-xs">{facts.join(" · ")}</div>
{flow.error != null && <div className="text-danger">{flow.error}</div>}
{actions.listed ? (
<Tooltip
wrap
content="Still tunnelled? The app may pin its certificate"
delay={CONTROL_TOOLTIP_DELAY}
>
<div>Decrypting new connections</div>
</Tooltip>
) : actions.blocked != null ? (
<Tooltip
wrap
content="Remove it from the decrypt list to decrypt"
delay={CONTROL_TOOLTIP_DELAY}
>
<div>
Excluded by <span className="font-mono text-xs">{actions.blocked}</span>
</div>
</Tooltip>
) : (
<div className="flex gap-2 mt-1">
{actions.host && (
<Button
size="xs"
variant="border"
title={`Decrypt new connections to ${host}`}
tooltip
disabled={save.isPending}
onClick={() => decrypt(hostSubject)}
>
Decrypt Host
</Button>
)}
{actions.app && appSubject != null && (
<Button
size="xs"
variant="border"
title={`Decrypt new connections from ${app}`}
tooltip
disabled={save.isPending}
onClick={() => decrypt(appSubject)}
>
Decrypt App
</Button>
)}
</div>
)}
</div>
</EmptyState>
);
}
@@ -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() {
>
<div className="h-full flex flex-col min-h-0">
<TakeOverBanner />
<HttpsStrip />
<div className="flex-1 min-h-0 grid grid-rows-[minmax(0,1fr)]">
<FlowsOrFirstRun flowLayout={flowLayout} />
</div>
+82 -50
View File
@@ -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<DropdownRef>(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 ? (
<MenuRow
icon={<Icon icon="shield_check" color="success" size="sm" />}
title="Certificate trusted"
description="Yaak Proxy CA, in your login keychain."
/>
) : (
<MenuRow
icon={<Icon icon="shield_off" color="warning" size="sm" />}
title="Certificate not trusted"
description={
trust.error != null ? (
<span className="text-danger">{trust.error.message}</span>
) : trust.isPending ? (
"Waiting for your password in the macOS prompt."
) : (
"So HTTPS reads as plain text. macOS asks for your login password once."
)
}
action={
<Button
size="2xs"
variant="border"
color="primary"
isLoading={trust.isPending}
onClick={(e) => {
e.stopPropagation();
trust.mutate({});
}}
>
{trust.error != null ? "Try again" : "Trust certificate"}
</Button>
}
/>
),
},
{ type: "content", label: <DecryptEverythingRow broken={broken} /> },
];
return (
@@ -235,7 +204,7 @@ export function ProxyStatus() {
<Icon icon="chevron_down" size="xs" className="text-text-subtlest" />
</Pill>
</Dropdown>
{untrusted && (
{broken && (
<Pill color="warning" onClick={() => dropdownRef.current?.open()}>
<Icon icon="shield_off" size="xs" />
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 (
<MenuRow
icon={<Icon icon="shield_off" size="sm" color="warning" />}
title="Decrypt everything"
description={<span className="text-warning">Certificate not trusted</span>}
action={
<Button
size="2xs"
color="primary"
isLoading={trusting}
onClick={(e) => {
e.stopPropagation();
trust.mutate({});
}}
>
{trusting ? "Waiting for macOS" : "Trust"}
</Button>
}
/>
);
}
return (
<MenuRow
icon={<Icon icon="lock_open" size="sm" color="secondary" />}
title="Decrypt everything"
description={
trusting
? "Waiting for macOS"
: on
? "Except Apple services and apps that pin"
: (listSummary(list) ?? "Nothing listed")
}
action={
<KeepMenuOpen>
<Checkbox
title="Decrypt everything"
hideLabel
checked={on}
disabled={trusting}
onChange={(checked) =>
void apply(
checked
? {
edit: everythingEdit(true),
message: "Decrypting everything",
sub: "Except Apple services and apps that pin",
}
: { edit: everythingEdit(false), message: "Stopped decrypting everything" },
)
}
/>
</KeepMenuOpen>
}
/>
);
}
/**
* 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<CaStatus>(["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 */
+2 -2
View File
@@ -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() {
</SettingsSection>
<SettingsSection title="Decryption">
<DecryptListEditor className="py-4" />
<DecryptionSettings />
</SettingsSection>
<SettingsSection title="Traffic">
+58 -22
View File
@@ -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 (
<div className="flex items-center gap-2 w-full min-w-0">
{item.kind === "all" && <Icon icon="layers" size="sm" className="text-text-subtle" />}
{item.kind === "tunnelled" && <Icon icon="lock" size="sm" className="text-text-subtle" />}
{item.kind === "encrypted" && <Icon icon="lock" size="sm" className="text-text-subtle" />}
{item.kind === "saved" && <Icon icon="bookmark" size="sm" className="text-text-subtle" />}
{item.kind === "folder" && <Icon icon="folder" size="sm" className="text-text-subtle" />}
{item.appPath != null && <AppIcon path={item.appPath} className="size-[1.125rem]" />}
@@ -88,7 +97,11 @@ function ItemInner({ item }: { item: SidebarItem }) {
{item.sublabel != null && (
<span className="truncate text-xs text-text-subtlest">{item.sublabel}</span>
)}
{item.decrypt != null && <DecryptedMark item={item} />}
{item.encryptedOnly && (
<Tooltip wrap content="Encrypted" delay={CONTROL_TOOLTIP_DELAY}>
<Icon icon="lock" size="xs" className="shrink-0 text-text-subtlest" />
</Tooltip>
)}
<Count item={item} />
</div>
);
@@ -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 (
<Tooltip wrap content="Decrypted" delay={CONTROL_TOOLTIP_DELAY}>
<Icon icon="lock_open" size="xs" className="shrink-0 text-text-subtlest" />
</Tooltip>
/** A checkable menu item's mark: a check when on, the same room when off */
function checkMark(on: boolean) {
return on ? (
<Icon icon="check" size="sm" />
) : (
<span aria-hidden className="inline-block w-4 h-4" />
);
}
@@ -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 ? (
<span aria-hidden className="inline-block w-4 h-4" />
) : (
<Icon icon="check" size="sm" />
),
leftSlot: checkMark(!hideSystem),
onSelect: () => setHideSystem(!hideSystem),
},
];
}
if (items.length === 1 && items[0]?.id === ENCRYPTED_ID) {
return withSeparators([
[
{
label: "Decrypt Everything",
leftSlot: <Icon icon="lock_open" />,
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<NonNullable<TreeProps<SidebarItem>["renderContextMenu"]>>(
+97 -14
View File
@@ -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<string, unknown> })
.__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<unknown> {
const req = (payload ?? {}) as Record<string, unknown>;
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,
};
}
+172
View File
@@ -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> = {}): 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;
}
+172
View File
@@ -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<CaStatus>(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<CaStatus>(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<boolean> => {
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: (
<div>
<div>{toast.message}</div>
{toast.sub != null && (
<div className="text-xs text-text-subtle mt-0.5">{toast.sub}</div>
)}
</div>
),
action: ({ hide }) => (
<div className="flex gap-2">
<Button
size="xs"
variant="border"
onClick={() => {
hide();
void run(inverseEdit(choice.edit));
}}
>
Undo
</Button>
{toast.reconnect.length > 0 && (
<Button
size="xs"
variant="border"
onClick={() => {
hide();
reconnect(toast.reconnect);
}}
>
Reconnect
</Button>
)}
</div>
),
});
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<PassthroughResponse>(PASSTHROUGH_KEY, payload),
[queryClient],
);
useRpcEvent("passthrough_changed", onChanged);
return useRpcQuery("list_passthrough", {}).data?.addresses ?? null;
}
-18
View File
@@ -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 }),
});
}
+25
View File
@@ -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();
});
});
+39
View File
@@ -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<string, string> = {
"application/javascript": "js",
+48 -5
View File
@@ -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"] });
});
});
+88 -2
View File
@@ -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[] {
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, test, vi } from "vite-plus/test";
import { applyChoice, type ChoiceDeps, choiceToast } from "./decryptChoice";
function deps(over: Partial<ChoiceDeps> = {}) {
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"]);
});
});
+79
View File
@@ -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<boolean>;
/** 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<ChoiceResult> {
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,
};
}
+21 -17
View File
@@ -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<string, string> } {
};
}
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);
});
+7 -6
View File
@@ -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;
+335
View File
@@ -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<EncryptedCardInput> = {}) {
return encryptedCard({
flow: tunnel,
list: BUILT_INS,
trusted: true,
canTrust: true,
passthrough: [],
...over,
});
}
const summary = (c: ReturnType<typeof card>) =>
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();
});
});
+404
View File
@@ -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<Flow, "url" | "clientName" | "clientBundleId">;
/** 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<Flow, "clientName" | "clientBundleId" | "clientPath"> | null;
}
/** A refused handshake, or a tunnel passed through because of one */
export function isRejection(flow: Pick<Flow, "tlsRejected" | "tunnelReason">): 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<Flow, "elapsedMs" | "error">): 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<Flow, "clientName" | "clientBundleId" | "clientPath"> | 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"],
};
}
+1 -19
View File
@@ -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");
});
});
-21
View File
@@ -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" };
}
+42 -4
View File
@@ -8,8 +8,14 @@ vi.mock("@yaakapp-internal/ui", async () => {
return { selectedIdsFamily: () => atom<string[]>([]) };
});
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));
});
});
+83
View File
@@ -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<Flow>, 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],
]);
});
});
+107
View File
@@ -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<Flow, "url" | "tunnelled" | "clientName" | "clientBundleId" | "clientPath">[],
list: DecryptListEntry[],
): { apps: ChooseApp[]; sites: ChooseSite[] } {
const builtIns = list.filter((e) => e.builtIn && e.entry.startsWith("!"));
const apps = new Map<string, ChooseApp>();
const sites = new Map<string, ChooseSite>();
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 = <T extends { count: number }>(rows: Iterable<T>) =>
[...rows].sort((a, b) => b.count - a.count).slice(0, CHOOSE_MAX);
return { apps: top(apps.values()), sites: top(sites.values()) };
}
+11 -2
View File
@@ -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,
});
});
});
+10 -4
View File
@@ -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 */
+14 -7
View File
@@ -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", () => {
+48 -26
View File
@@ -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<string, Trie>;
/** 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<SidebarItem> = {
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;
}
+32 -13
View File
@@ -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<FlowQuery | null>((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<SavedRow[]>((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: [] },
+2 -1
View File
@@ -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;
+5 -2
View File
@@ -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<string | null>(null);
const openStore = createStore();
export const Dropdown = forwardRef<DropdownRef, DropdownProps>(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<DropdownRef, DropdownProps>(function Dropdown
const newIsOpen = globalOpenId === id.current;
if (newIsOpen !== isOpen) {
setIsOpen(newIsOpen);
if (!newIsOpen) onClose?.();
}
});
}, [isOpen]);
}, [isOpen, onClose]);
// const [isOpen, _setIsOpen] = useState<boolean>(false);
const [defaultSelectedIndex, setDefaultSelectedIndex] = useState<number | null>(null);
@@ -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<TabItem[]>(
() => [
...(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({
<TabContent value={REQUEST_TAB_BODY} ErrorBoundary={ErrorBoundary}>
{renderBody ? renderBody(sharedBody) : sharedBody}
</TabContent>
{extraTabs?.map(({ tab, content }) => (
{[...(leadingTabs ?? []), ...(extraTabs ?? [])].map(({ tab, content }) => (
<TabContent key={tab.value} value={tab.value} ErrorBoundary={ErrorBoundary}>
{content}
</TabContent>
@@ -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,