Yaak Proxy: fix review findings in the decryption UI

Count Reconnect's connections within its own scope, refresh the
pass-through set from Try Again's answer, report a failed trust, keep
the pill's switch where the app can't trust the certificate, and keep a
failed tunnel's error across Type and Size.

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 b0425069f4
commit f5ff9cb63e
8 files changed
+91 -12

No files matched your search

+9 -2
View File
@@ -1,5 +1,6 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow } from "@yaakapp-internal/proxy-lib";
import { useQueryClient } from "@tanstack/react-query";
import type { Flow, PassthroughResponse } from "@yaakapp-internal/proxy-lib";
import {
Button,
Dropdown,
@@ -59,7 +60,13 @@ export function EncryptedCard({ flow }: { flow: Flow }) {
const showToast = useShowToast();
const fail = (err: Error) =>
showToast({ id: "encrypted-card", color: "danger", message: err.message });
const forget = useRpcMutation("forget_passthrough", { onError: fail });
const queryClient = useQueryClient();
// Read from the answer too: an engine the CLI owns tells this app nothing of the change.
const forget = useRpcMutation("forget_passthrough", {
onError: fail,
onSuccess: ({ addresses }) =>
queryClient.setQueryData<PassthroughResponse>(["list_passthrough", {}], { addresses }),
});
const reconnect = useRpcMutation("reconnect_tunnels", { onError: fail });
const connection = connectionOf(flow);
const [busy, setBusy] = useState<string | null>(null);
+10
View File
@@ -1044,6 +1044,16 @@ function encryptedCell({ column, flow, span, selected }: CellProps): ReactElemen
</Truncated>
);
}
case "type":
// A tunnel that failed to reach the server keeps the failure look, across Type and Size
if (flow.error == null) return <span className={cell} />;
return (
<Tooltip wrap content={flow.error} delay={CONTROL_TOOLTIP_DELAY}>
<span className={classNames(cell, "text-danger text-xs", span && "col-span-2")}>
{flow.error}
</span>
</Tooltip>
);
case "size":
return (
<span className={classNames(quiet, "text-right")}>
+7 -2
View File
@@ -13,7 +13,7 @@ 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 { canTrustHere, useDecrypt, useTrustCertificate } from "../hooks/useDecrypt";
import { useDecryptList } from "../hooks/useDecryptList";
import { useRpcEvent } from "../hooks/useRpcEvent";
import { useRpcMutation } from "../hooks/useRpcMutation";
@@ -51,7 +51,12 @@ export function ProxyStatus() {
const port = proxyState?.port ?? other?.proxyPort ?? null;
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);
// Only where the app can trust it itself: elsewhere the status always reads untrusted.
const broken =
canTrustHere() &&
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;
+10
View File
@@ -324,6 +324,16 @@ async function answer(cmd: string, payload: unknown): Promise<unknown> {
}
case "reconnect_tunnels":
return { closed: 1 };
case "list_reconnectable_tunnels":
return Array.from({ length: Number(params.get("still") ?? 3) }, (_, i) => ({
id: `harness_${i}`,
host: "www.google.com",
port: 443,
appName: "Dia",
appBundleId: null,
flowId: null,
reason: "not_listed",
}));
case "system_proxy_status":
return {
enabled: !params.has("firstrun"),
+32 -2
View File
@@ -3,7 +3,13 @@ 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 {
applyChoice,
choiceToast,
type DecryptChoice,
type ListEdit,
reconnectEntries,
} from "../lib/decryptChoice";
import { rpc } from "../lib/rpc";
import { getOsType } from "../lib/tauri";
import { useShowToast } from "../lib/toast";
@@ -30,10 +36,18 @@ export function useTrusting(): boolean {
/** Asks macOS to trust the certificate, the status read from its answer */
export function useTrustCertificate() {
const queryClient = useQueryClient();
const showToast = useShowToast();
return useRpcMutation("ca_trust", {
mutationKey: TRUST_KEY,
onSuccess: ({ trusted, installed, certPath }) =>
queryClient.setQueryData<CaStatus>(CA_KEY, { trusted, installed, certPath }),
// A cancelled prompt isn't an error, and says nothing; a failure says why.
onError: (err) =>
showToast({
id: "decrypt",
color: "danger",
message: `Certificate not trusted: ${err.message}`,
}),
});
}
@@ -110,7 +124,10 @@ export function useDecrypt() {
});
return false;
}
const toast = choiceToast(choice, result.stillEncrypted);
// Counted within what Reconnect closes, not every open connection the list now decrypts
const scoped =
result.stillEncrypted > 0 ? await countReconnectable(reconnectEntries(choice.edit)) : 0;
const toast = choiceToast(choice, scoped);
showToast({
id: "decrypt",
message: (
@@ -156,6 +173,19 @@ export function useDecrypt() {
return { apply, trusting };
}
/** Open tunnels the entries name that the list now decrypts, each counted once */
async function countReconnectable(entries: string[]): Promise<number> {
const ids = new Set<string>();
for (const entry of entries) {
try {
for (const t of await rpc("list_reconnectable_tunnels", { entry })) ids.add(t.id);
} catch (err) {
console.warn("Failed to list reconnectable tunnels", err);
}
}
return ids.size;
}
/**
* The addresses passed through after clients rejected the certificate, kept current as the
* engine adds and forgets them. Null until read.
+9 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test, vi } from "vite-plus/test";
import { applyChoice, type ChoiceDeps, choiceToast } from "./decryptChoice";
import { applyChoice, type ChoiceDeps, choiceToast, reconnectEntries } from "./decryptChoice";
function deps(over: Partial<ChoiceDeps> = {}) {
const calls: string[] = [];
@@ -117,3 +117,11 @@ describe("the toast after a choice", () => {
expect(choiceToast(stopExcluding, 1).reconnect).toEqual(["!host:*.chase.com"]);
});
});
describe("what Reconnect covers", () => {
test("the entries a change added, and the exclusions it took off, never what it stopped", () => {
expect(
reconnectEntries({ add: ["host:a.com", "!app:Dia"], remove: ["!host:*.b.com", "app:Slack"] }),
).toEqual(["host:a.com", "!host:*.b.com"]);
});
});
+12 -4
View File
@@ -48,6 +48,17 @@ export async function applyChoice(deps: ChoiceDeps, edit: ListEdit): Promise<Cho
return { status: "applied", stillEncrypted: saved.stillEncrypted };
}
/**
* The entries whose open connections a change can make decryptable, which Reconnect closes and
* the toast counts: those it added, and exclusions it took off
*/
export function reconnectEntries(edit: ListEdit): string[] {
return [
...edit.add.filter((e) => !e.trim().startsWith("!")),
...edit.remove.filter((e) => e.trim().startsWith("!")),
];
}
/** The toast after a choice was applied */
export interface ChoiceToast {
message: string;
@@ -67,10 +78,7 @@ export function choiceToast(choice: DecryptChoice, stillEncrypted: number): Choi
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("!")),
];
const reconnect = reconnectEntries(choice.edit);
return {
message: choice.message,
sub: `${n} open ${stillEncrypted === 1 ? "connection stays" : "connections stay"} encrypted`,
+2 -1
View File
@@ -340,7 +340,8 @@ export function buildSidebarTree(
parent.item.count++;
addPlace(app, place.appRows, flow.tunnelled);
}
if (encrypted > 0 && encrypted < flows.length) {
// Once something is decrypted: a refused handshake isn't, so it doesn't count
if (encrypted > 0 && flows.some((f) => !f.tunnelled && f.tlsRejected == null)) {
for (const g of [apps, domains, system]) markEncrypted(g);
}
for (const site of domains.children.values()) domains.item.count += site.children.size;