Yaak Proxy: code-styled names and an info button in the encrypted card's why line

Hosts, apps and entries named in the why and opened-before lines render
as InlineCode, an info button beside the why line explains decryption in
a tooltip, and the "New connections only" line is gone.

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 ae666adfb7
commit f2555bdcd5
3 files changed
+90 -54

No files matched your search

+37 -8
View File
@@ -9,6 +9,8 @@ import {
IconButton, IconButton,
type IconProps, type IconProps,
InlineCode, InlineCode,
Tooltip,
CONTROL_TOOLTIP_DELAY,
} from "@yaakapp-internal/ui"; } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { type ReactNode, useEffect, useState } from "react"; import { type ReactNode, useEffect, useState } from "react";
@@ -22,6 +24,7 @@ import {
encryptedCard, encryptedCard,
isOpen, isOpen,
isRejection, isRejection,
type Line,
} from "../lib/encryptedCard"; } from "../lib/encryptedCard";
import { useFlowActions } from "../lib/flowActions"; import { useFlowActions } from "../lib/flowActions";
import { utcTimestamp } from "../lib/flowViews"; import { utcTimestamp } from "../lib/flowViews";
@@ -122,17 +125,22 @@ export function EncryptedCard({ flow }: { flow: Flow }) {
<div className="mx-3 border-t border-border-subtle overflow-auto"> <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="mx-auto max-w-[27rem] py-10 grid gap-2">
<div className="text-sm text-text mb-1"> <div className="text-sm text-text mb-1">
{model.why} <LineText line={model.why} />
{model.whyEntry != null && (
<>
{" "}
<InlineCode>{model.whyEntry}</InlineCode>
</>
)}
{model.whySub != null && <span className="text-text-subtle"> · {model.whySub}</span>} {model.whySub != null && <span className="text-text-subtle"> · {model.whySub}</span>}
<Tooltip wrap content={ABOUT} delay={CONTROL_TOOLTIP_DELAY}>
<IconButton
size="2xs"
icon="info"
iconColor="secondary"
title="About decryption"
className="ml-1 align-middle inline-flex!"
/>
</Tooltip>
</div> </div>
{model.fact != null && ( {model.fact != null && (
<div className="text-sm text-text-subtle -mt-1 mb-1">{model.fact}</div> <div className="text-sm text-text-subtle -mt-1 mb-1">
<LineText line={model.fact} />
</div>
)} )}
{model.actions.map((action) => ( {model.actions.map((action) => (
<ActionButton <ActionButton
@@ -165,6 +173,27 @@ export function EncryptedCard({ flow }: { flow: Flow }) {
); );
} }
/** What the info button beside the why line says */
const ABOUT =
"HTTPS is encrypted between the app and the server. Yaak Proxy shows requests only for hosts and apps you decrypt, from their next connection on.";
/** A why or fact line, its hosts, apps and entries shown as code */
function LineText({ line }: { line: Line }) {
return (
<>
{line.map((part, i) =>
typeof part === "string" ? (
// oxlint-disable-next-line react/no-array-index-key -- the parts of one fixed line
<span key={i}>{part}</span>
) : (
// oxlint-disable-next-line react/no-array-index-key -- the parts of one fixed line
<InlineCode key={i}>{part.code}</InlineCode>
),
)}
</>
);
}
const ICONS: Record<Exclude<CardAction["icon"], "app">, IconProps["icon"]> = { const ICONS: Record<Exclude<CardAction["icon"], "app">, IconProps["icon"]> = {
host: "globe", host: "globe",
everything: "layers", everything: "layers",
+28 -22
View File
@@ -4,6 +4,7 @@ import {
connectionOf, connectionOf,
type EncryptedCardInput, type EncryptedCardInput,
encryptedCard, encryptedCard,
lineText,
stopDecryptingConnection, stopDecryptingConnection,
} from "./encryptedCard"; } from "./encryptedCard";
@@ -67,7 +68,7 @@ describe("connectionOf", () => {
describe("the card of an encrypted connection", () => { describe("the card of an encrypted connection", () => {
test("nothing decrypted yet: the host, the app and everything, each with what it writes", () => { test("nothing decrypted yet: the host, the app and everything, each with what it writes", () => {
const c = card(); const c = card();
expect(c.why).toBe("Nothing is decrypted yet"); expect(lineText(c.why)).toBe("Nothing is decrypted yet");
expect(summary(c)).toEqual([ expect(summary(c)).toEqual([
[ [
"Decrypt www.google.com", "Decrypt www.google.com",
@@ -77,21 +78,19 @@ describe("the card of an encrypted connection", () => {
["Decrypt Dia", "app:Dia", { add: ["app:Dia"], remove: [] }], ["Decrypt Dia", "app:Dia", { add: ["app:Dia"], remove: [] }],
["Decrypt Everything", "*", { add: ["*"], remove: [] }], ["Decrypt Everything", "*", { add: ["*"], remove: [] }],
]); ]);
expect(c.foot).toEqual(["New connections only"]); // Nothing under the buttons while the certificate is trusted
expect(c.foot).toEqual([]);
expect(c.fact).toBeNull(); expect(c.fact).toBeNull();
}); });
test("asks for the password once while the certificate isn't trusted, where it can be", () => { test("asks for the password once while the certificate isn't trusted, where it can be", () => {
expect(card({ trusted: false }).foot).toEqual([ expect(card({ trusted: false }).foot).toEqual(["Asks for your password once"]);
"New connections only", expect(card({ trusted: false, canTrust: false }).foot).toEqual([]);
"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", () => { test("something else listed: not on the list, with the same choices", () => {
const c = card({ list: [...user("host:api.stripe.com"), ...BUILT_INS] }); const c = card({ list: [...user("host:api.stripe.com"), ...BUILT_INS] });
expect(c.why).toBe("Decryption not configured for www.google.com"); expect(lineText(c.why)).toBe("Decryption not configured for `www.google.com`");
expect(c.actions.map((a) => a.label)).toEqual([ expect(c.actions.map((a) => a.label)).toEqual([
"Decrypt www.google.com", "Decrypt www.google.com",
"Decrypt Dia", "Decrypt Dia",
@@ -108,11 +107,11 @@ describe("the card of an encrypted connection", () => {
test("names the host nothing on the list covers, its port only when not 443", () => { test("names the host nothing on the list covers, its port only when not 443", () => {
const list = [...user("host:api.stripe.com"), ...BUILT_INS]; const list = [...user("host:api.stripe.com"), ...BUILT_INS];
expect(card({ list, flow: { ...tunnel, url: "t.yaak.app:443" } }).why).toBe( expect(lineText(card({ list, flow: { ...tunnel, url: "t.yaak.app:443" } }).why)).toBe(
"Decryption not configured for t.yaak.app", "Decryption not configured for `t.yaak.app`",
); );
expect(card({ list, flow: { ...tunnel, url: "home.ts.net:8317" } }).why).toBe( expect(lineText(card({ list, flow: { ...tunnel, url: "home.ts.net:8317" } }).why)).toBe(
"Decryption not configured for home.ts.net:8317", "Decryption not configured for `home.ts.net:8317`",
); );
}); });
@@ -139,7 +138,10 @@ describe("the card of an encrypted connection", () => {
tunnelExcludedBuiltIn: true, tunnelExcludedBuiltIn: true,
}, },
}); });
expect([pinned.why, pinned.whySub]).toEqual(["Excluded by default", "pins its certificate"]); expect([lineText(pinned.why), pinned.whySub]).toEqual([
"Excluded by default",
"pins its certificate",
]);
expect(summary(pinned)).toEqual([ expect(summary(pinned)).toEqual([
[ [
"Decrypt my.1password.com", "Decrypt my.1password.com",
@@ -171,7 +173,7 @@ describe("the card of an encrypted connection", () => {
list, list,
flow: { ...tunnel, tunnelReason: "excluded", tunnelExcludedBy: "!host:*.google.com" }, flow: { ...tunnel, tunnelReason: "excluded", tunnelExcludedBy: "!host:*.google.com" },
}); });
expect([c.why, c.whyEntry]).toEqual(["Excluded by", "!host:*.google.com"]); expect(lineText(c.why)).toBe("Excluded by `!host:*.google.com`");
// Nothing added wins over the person's own exclusion, so taking it off is the way in // Nothing added wins over the person's own exclusion, so taking it off is the way in
expect(summary(c)).toEqual([ expect(summary(c)).toEqual([
[ [
@@ -204,8 +206,10 @@ describe("the card of an encrypted connection", () => {
test("once the list decrypts it: opened before, the opposite, and Reconnect while open", () => { test("once the list decrypts it: opened before, the opposite, and Reconnect while open", () => {
const list = [...user("host:www.google.com"), ...BUILT_INS]; const list = [...user("host:www.google.com"), ...BUILT_INS];
const closed = card({ list }); const closed = card({ list });
expect(closed.why).toBe("Not on the decrypt list"); expect(lineText(closed.why)).toBe("Not on the decrypt list");
expect(closed.fact).toBe("Opened before www.google.com was decrypted"); expect(closed.fact && lineText(closed.fact)).toBe(
"Opened before `www.google.com` was decrypted",
);
expect(summary(closed)).toEqual([ expect(summary(closed)).toEqual([
[ [
"Stop Decrypting www.google.com", "Stop Decrypting www.google.com",
@@ -219,7 +223,9 @@ describe("the card of an encrypted connection", () => {
expect(open.actions.map((a) => a.kind)).toEqual(["reconnect", "stop"]); expect(open.actions.map((a) => a.kind)).toEqual(["reconnect", "stop"]);
const everything = card({ list: [...user("*"), ...BUILT_INS] }); const everything = card({ list: [...user("*"), ...BUILT_INS] });
expect(everything.fact).toBe("Opened before everything was decrypted"); expect(everything.fact && lineText(everything.fact)).toBe(
"Opened before everything was decrypted",
);
expect(everything.actions[0]?.label).toBe("Stop Decrypting Everything"); expect(everything.actions[0]?.label).toBe("Stop Decrypting Everything");
}); });
}); });
@@ -240,7 +246,7 @@ describe("the card of a refused handshake", () => {
test("an app: likely pinned, Stop Decrypting first, Try Again while passed through", () => { 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"] }); const c = card({ flow: refused, list: listed, passthrough: ["sync.acme.dev:443"] });
expect(c.rejected).toBe(true); expect(c.rejected).toBe(true);
expect([c.why, c.whySub]).toEqual(["Acme rejected the certificate", "likely pinned"]); expect([lineText(c.why), c.whySub]).toEqual(["Acme rejected the certificate", "likely pinned"]);
expect(summary(c)).toEqual([ expect(summary(c)).toEqual([
["Stop Decrypting Acme", "!app:Acme", { add: ["!app:Acme"], remove: [] }], ["Stop Decrypting Acme", "!app:Acme", { add: ["!app:Acme"], remove: [] }],
["Try Again", "next connection", null], ["Try Again", "next connection", null],
@@ -264,7 +270,7 @@ describe("the card of a refused handshake", () => {
clientPath: "/opt/homebrew/bin/python3", clientPath: "/opt/homebrew/bin/python3",
}; };
const c = card({ flow: python, list: listed, passthrough: ["pypi.org:443"] }); const c = card({ flow: python, list: listed, passthrough: ["pypi.org:443"] });
expect([c.why, c.whySub]).toEqual([ expect([lineText(c.why), c.whySub]).toEqual([
"python3 rejected the certificate", "python3 rejected the certificate",
"uses its own trust store", "uses its own trust store",
]); ]);
@@ -281,7 +287,7 @@ describe("the card of a refused handshake", () => {
flow: { ...refused, clientName: null, clientBundleId: null, clientPath: null }, flow: { ...refused, clientName: null, clientBundleId: null, clientPath: null },
list: listed, list: listed,
}); });
expect(c.why).toBe("A client rejected the certificate"); expect(lineText(c.why)).toBe("A client rejected the certificate");
expect(summary(c)[0]).toEqual([ expect(summary(c)[0]).toEqual([
"Stop Decrypting sync.acme.dev", "Stop Decrypting sync.acme.dev",
"!host:sync.acme.dev", "!host:sync.acme.dev",
@@ -296,7 +302,7 @@ describe("the card of a refused handshake", () => {
trusted: false, trusted: false,
passthrough: ["sync.acme.dev:443"], passthrough: ["sync.acme.dev:443"],
}); });
expect(c.why).toBe("Certificate not trusted"); expect(lineText(c.why)).toBe("Certificate not trusted");
expect(c.actions.map((a) => a.kind)).toEqual(["trust", "stop"]); expect(c.actions.map((a) => a.kind)).toEqual(["trust", "stop"]);
expect(c.foot).toEqual(["Passed through until trusted"]); expect(c.foot).toEqual(["Passed through until trusted"]);
}); });
@@ -312,7 +318,7 @@ describe("the card of a refused handshake", () => {
list: listed, list: listed,
rejectedBy: { clientName: "Acme", clientBundleId: "dev.acme", clientPath: null }, rejectedBy: { clientName: "Acme", clientBundleId: "dev.acme", clientPath: null },
}); });
expect(c.why).toBe("Acme rejected the certificate for this host"); expect(lineText(c.why)).toBe("Acme rejected the certificate for this host");
// Stop Decrypting acts on this connection, so on Dia's own // Stop Decrypting acts on this connection, so on Dia's own
expect(c.actions[0]?.hint).toBe("!app:Dia"); expect(c.actions[0]?.hint).toBe("!app:Dia");
}); });
+25 -24
View File
@@ -78,16 +78,22 @@ export interface CardAction {
icon: "host" | "app" | "everything" | "lock" | "lock_open" | "refresh" | "shield" | "terminal"; icon: "host" | "app" | "everything" | "lock" | "lock_open" | "refresh" | "shield" | "terminal";
} }
/** A line of text, the hosts, apps and entries in it shown as code, as Yaak shows literal values */
export type Line = (string | { code: string })[];
/** A line as plain text, its code in backticks */
export function lineText(line: Line): string {
return line.map((part) => (typeof part === "string" ? part : `\`${part.code}\``)).join("");
}
export interface EncryptedCardModel { export interface EncryptedCardModel {
rejected: boolean; rejected: boolean;
/** Why the connection is encrypted, in one line */ /** Why the connection is encrypted, in one line */
why: string; why: Line;
/** Quieter, after it */ /** Quieter, after it */
whySub: string | null; 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 */ /** A fact about the connection since the list changed: when it opened */
fact: string | null; fact: Line | null;
actions: CardAction[]; actions: CardAction[];
/** Small lines under the buttons */ /** Small lines under the buttons */
foot: string[]; foot: string[];
@@ -142,8 +148,8 @@ function tunnelCard({ flow, list, trusted, canTrust }: EncryptedCardInput): Encr
const c = connectionOf(flow); const c = connectionOf(flow);
const q = queryOf(c); const q = queryOf(c);
const decryptsNow = decrypts(list, q); const decryptsNow = decrypts(list, q);
const { why, whySub, whyEntry } = whyLine(flow, list, c, decryptsNow); const { why, whySub } = whyLine(flow, list, c, decryptsNow);
const foot = ["New connections only"]; const foot: string[] = [];
if (decryptsNow) { if (decryptsNow) {
// Decrypted from the next connection on: the entries that do it, each to take back. // Decrypted from the next connection on: the entries that do it, each to take back.
@@ -155,14 +161,13 @@ function tunnelCard({ flow, list, trusted, canTrust }: EncryptedCardInput): Encr
actions.unshift({ kind: "reconnect", label: "Reconnect", hint: null, icon: "refresh" }); actions.unshift({ kind: "reconnect", label: "Reconnect", hint: null, icon: "refresh" });
} }
const first = decrypting[0]?.entry; const first = decrypting[0]?.entry;
const what = const what: Line[number] =
first == null ? c.label : first === EVERYTHING ? "everything" : entrySubject(first); first === EVERYTHING ? "everything" : { code: first == null ? c.label : entrySubject(first) };
return { return {
rejected: false, rejected: false,
why, why,
whySub, whySub,
whyEntry, fact: ["Opened before ", what, " was decrypted"],
fact: `Opened before ${what} was decrypted`,
actions, actions,
foot: [], foot: [],
}; };
@@ -230,7 +235,6 @@ function tunnelCard({ flow, list, trusted, canTrust }: EncryptedCardInput): Encr
rejected: false, rejected: false,
why, why,
whySub, whySub,
whyEntry,
fact: null, fact: null,
actions, actions,
foot: actions.length === 0 ? [] : foot, foot: actions.length === 0 ? [] : foot,
@@ -246,24 +250,23 @@ function whyLine(
list: DecryptListEntry[], list: DecryptListEntry[],
c: Connection, c: Connection,
decryptsNow: boolean, decryptsNow: boolean,
): { why: string; whySub: string | null; whyEntry: string | null } { ): { why: Line; whySub: string | null } {
if (flow.tunnelReason === "excluded" && flow.tunnelExcludedBy != null) { if (flow.tunnelReason === "excluded" && flow.tunnelExcludedBy != null) {
if (flow.tunnelExcludedBuiltIn) { if (flow.tunnelExcludedBuiltIn) {
const kind = list.find((e) => e.entry === flow.tunnelExcludedBy)?.kind; const kind = list.find((e) => e.entry === flow.tunnelExcludedBy)?.kind;
const whySub = const whySub =
kind === "apple" ? "Apple service" : kind === "pinned" ? "pins its certificate" : null; kind === "apple" ? "Apple service" : kind === "pinned" ? "pins its certificate" : null;
return { why: "Excluded by default", whySub, whyEntry: null }; return { why: ["Excluded by default"], whySub };
} }
return { why: "Excluded by", whySub: null, whyEntry: flow.tunnelExcludedBy }; return { why: ["Excluded by ", { code: flow.tunnelExcludedBy }], whySub: null };
} }
return { return {
why: !decryptsAnything(list) why: !decryptsAnything(list)
? "Nothing is decrypted yet" ? ["Nothing is decrypted yet"]
: decryptsNow : decryptsNow
? "Not on the decrypt list" ? ["Not on the decrypt list"]
: `Decryption not configured for ${c.label}`, : ["Decryption not configured for ", { code: c.label }],
whySub: null, whySub: null,
whyEntry: null,
}; };
} }
@@ -362,9 +365,8 @@ function rejectedCard(input: EncryptedCardInput): EncryptedCardModel {
if (canTrust && trusted === false && decryptsAnything(list)) { if (canTrust && trusted === false && decryptsAnything(list)) {
return { return {
rejected: true, rejected: true,
why: "Certificate not trusted", why: ["Certificate not trusted"],
whySub: null, whySub: null,
whyEntry: null,
fact: null, fact: null,
actions: [ actions: [
{ kind: "trust", label: "Trust Certificate", hint: null, primary: true, icon: "shield" }, { kind: "trust", label: "Trust Certificate", hint: null, primary: true, icon: "shield" },
@@ -378,9 +380,8 @@ function rejectedCard(input: EncryptedCardInput): EncryptedCardModel {
if (refuser.kind === "program") { if (refuser.kind === "program") {
return { return {
rejected: true, rejected: true,
why: `${refuser.name} rejected the certificate${forHost}`, why: [`${refuser.name} rejected the certificate${forHost}`],
whySub: "uses its own trust store", whySub: "uses its own trust store",
whyEntry: null,
fact: null, fact: null,
actions: [ actions: [
{ {
@@ -398,12 +399,12 @@ function rejectedCard(input: EncryptedCardInput): EncryptedCardModel {
} }
return { return {
rejected: true, rejected: true,
why: why: [
refuser.kind === "app" refuser.kind === "app"
? `${refuser.name} rejected the certificate${forHost}` ? `${refuser.name} rejected the certificate${forHost}`
: "A client rejected the certificate", : "A client rejected the certificate",
],
whySub: refuser.kind === "app" ? "likely pinned" : null, whySub: refuser.kind === "app" ? "likely pinned" : null,
whyEntry: null,
fact: null, fact: null,
actions: [ actions: [
...(stop == null ? [] : [{ ...stop, primary: true }]), ...(stop == null ? [] : [{ ...stop, primary: true }]),