From f5ff9cb63ef301baf7604d6385a320c773275848 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 10 Nov 2026 15:32:05 -0700 Subject: [PATCH] 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 --- apps/yaak-proxy/components/EncryptedCard.tsx | 11 +++++-- apps/yaak-proxy/components/FlowList.tsx | 10 ++++++ apps/yaak-proxy/components/ProxyStatus.tsx | 9 ++++-- apps/yaak-proxy/harness/backend.ts | 10 ++++++ apps/yaak-proxy/hooks/useDecrypt.tsx | 34 ++++++++++++++++++-- apps/yaak-proxy/lib/decryptChoice.test.ts | 10 +++++- apps/yaak-proxy/lib/decryptChoice.ts | 16 ++++++--- apps/yaak-proxy/lib/sidebarTree.ts | 3 +- 8 files changed, 91 insertions(+), 12 deletions(-) diff --git a/apps/yaak-proxy/components/EncryptedCard.tsx b/apps/yaak-proxy/components/EncryptedCard.tsx index d9be5359f..86bc6e8a7 100644 --- a/apps/yaak-proxy/components/EncryptedCard.tsx +++ b/apps/yaak-proxy/components/EncryptedCard.tsx @@ -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(["list_passthrough", {}], { addresses }), + }); const reconnect = useRpcMutation("reconnect_tunnels", { onError: fail }); const connection = connectionOf(flow); const [busy, setBusy] = useState(null); diff --git a/apps/yaak-proxy/components/FlowList.tsx b/apps/yaak-proxy/components/FlowList.tsx index a1f1b4043..4f81483a8 100644 --- a/apps/yaak-proxy/components/FlowList.tsx +++ b/apps/yaak-proxy/components/FlowList.tsx @@ -1044,6 +1044,16 @@ function encryptedCell({ column, flow, span, selected }: CellProps): ReactElemen ); } + case "type": + // A tunnel that failed to reach the server keeps the failure look, across Type and Size + if (flow.error == null) return ; + return ( + + + {flow.error} + + + ); case "size": return ( diff --git a/apps/yaak-proxy/components/ProxyStatus.tsx b/apps/yaak-proxy/components/ProxyStatus.tsx index a2f6b67c8..4e5f185f8 100644 --- a/apps/yaak-proxy/components/ProxyStatus.tsx +++ b/apps/yaak-proxy/components/ProxyStatus.tsx @@ -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; diff --git a/apps/yaak-proxy/harness/backend.ts b/apps/yaak-proxy/harness/backend.ts index e70f4392c..b9d57cebf 100644 --- a/apps/yaak-proxy/harness/backend.ts +++ b/apps/yaak-proxy/harness/backend.ts @@ -324,6 +324,16 @@ async function answer(cmd: string, payload: unknown): Promise { } 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"), diff --git a/apps/yaak-proxy/hooks/useDecrypt.tsx b/apps/yaak-proxy/hooks/useDecrypt.tsx index 39d5da10e..b06784888 100644 --- a/apps/yaak-proxy/hooks/useDecrypt.tsx +++ b/apps/yaak-proxy/hooks/useDecrypt.tsx @@ -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(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 { + const ids = new Set(); + 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. diff --git a/apps/yaak-proxy/lib/decryptChoice.test.ts b/apps/yaak-proxy/lib/decryptChoice.test.ts index 217c40958..2b64005ef 100644 --- a/apps/yaak-proxy/lib/decryptChoice.test.ts +++ b/apps/yaak-proxy/lib/decryptChoice.test.ts @@ -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 = {}) { 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"]); + }); +}); diff --git a/apps/yaak-proxy/lib/decryptChoice.ts b/apps/yaak-proxy/lib/decryptChoice.ts index c913f4e08..871478c0f 100644 --- a/apps/yaak-proxy/lib/decryptChoice.ts +++ b/apps/yaak-proxy/lib/decryptChoice.ts @@ -48,6 +48,17 @@ export async function applyChoice(deps: ChoiceDeps, edit: ListEdit): Promise !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`, diff --git a/apps/yaak-proxy/lib/sidebarTree.ts b/apps/yaak-proxy/lib/sidebarTree.ts index 0a93d9d4d..3f557c6b4 100644 --- a/apps/yaak-proxy/lib/sidebarTree.ts +++ b/apps/yaak-proxy/lib/sidebarTree.ts @@ -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;